diff --git a/packages/react-components/token-analyzer-preview/library/README.md b/packages/react-components/token-analyzer-preview/library/README.md index 0bc3d2109fc359..6fcf0d787a2bbb 100644 --- a/packages/react-components/token-analyzer-preview/library/README.md +++ b/packages/react-components/token-analyzer-preview/library/README.md @@ -4,19 +4,26 @@ A static analysis tool that scans your project's style files to track and analyz ## TODO +- we also need to ensure var analysis is done correctly after the refactor +- ~~**This is high pri now since we have components in source using this technique (see buttonstyles.styles.ts)** Handle very complex cases like `var(--optional-token, var(--semantic-token, ${some-other-var-with-a-string-or-fallback}))`. This other var might be in another package or file as well. Currently we won't handle this level of depth but we could do symbol extraction in the future if needed to resolve the chain fully. This will likely require changes in importAnalyzer.ts and structural changes in the data we return. On top of needing to find referenced symbols within an aliased template string literal, we might also then need to parse out var fallbacks within short hands. IE: `padding: 'var(--first, var(--second)) 10px` and ensure the ordering is correct.~~ +- ~~Format output with prettier when we save to ensure stage lint doesn't fail.~~ +- ~~make sure this works with shorthand spread~~ +- Look at the path info again. Do we ever need it? +- Convert token member within the analysis output to an array so we can hold multiple tokens. The order should be the order or priority. [0] being the highest pri with the last item in the array the least prioritized. +- Duplicate entries in useButtonStyles.styles.ts for useRootDisabledStyles.base.nested:hover.color - we might need to test case this +- ~~We've added the ability to analyze spreads but there's an issue where we find the tokens and call them out but they get nuked somewhere before we return them. Need to trace that and fix.~~ +- Add makeResetStyles specific tests in analyzer to ensure we process those correctly. +- ~~Button has some weird patterns in it where it uses makeResetStyles and then uses enums to pull in the styles, we might need to account for those as well.~~ - ~~Some property assignments can also be function calls, we need to process this scenario~~ - ~~`createCustomFocusIndicatorStyle` is a special function that is used throughout the library so we might be able to special case it~~ -- if we have file imports we need to analyze those such as importing base styles -- we also need to ensure var analysis is done correctly after the refactor +- ~~if we have file imports we need to analyze those such as importing base styles~~ ~~- Manage makeResetStyles (likely same as makeStyles)~~ -- Button has some weird patterns in it where it uses makeResetStyles and then uses enums to pull in the styles, we might need to account for those as well. - ~~what if we have multiple `makeStyles` calls merged, are we handling that correctly or just nuking the conflicts in our output?~~ -- make sure this works with shorthand spread - as we update the functionality, we should update our test cases to reflect the new functionality we support and ensure it works. - ~~if we have functions we can't process (or other code for that matter), can we add that data into our report so we know to manually go deal with it?~~ - ~~assignedSlots in output to track which slots classes are applied to~~ - ~~Add variables full name to metadata (i.e. classNames.icon instead of just 'icon)~~ -- Module importing +- ~~Module importing~~ ## Features diff --git a/packages/react-components/token-analyzer-preview/library/analysis.json b/packages/react-components/token-analyzer-preview/library/analysis.json index d19dd9bcd388d3..bd0956ec034ba0 100644 --- a/packages/react-components/token-analyzer-preview/library/analysis.json +++ b/packages/react-components/token-analyzer-preview/library/analysis.json @@ -7,65 +7,62 @@ { "property": "backgroundColor", "token": "tokens.colorNeutralBackground1", - "path": [ - "backgroundColor" - ] + "path": ["backgroundColor"] }, { "property": "color", "token": "tokens.colorNeutralForeground1", - "path": [ - "color" - ] + "path": ["color"] + }, + { + "property": "border", + "token": "tokens.strokeWidthThin", + "path": ["border"] + }, + { + "property": "border", + "token": "tokens.colorNeutralStroke1", + "path": ["border"] }, { "property": "fontFamily", "token": "tokens.fontFamilyBase", - "path": [ - "fontFamily" - ] + "path": ["fontFamily"] + }, + { + "property": "padding", + "token": "tokens.spacingHorizontalM", + "path": ["padding"] }, { "property": "borderRadius", "token": "tokens.borderRadiusMedium", - "path": [ - "borderRadius" - ] + "path": ["borderRadius"] }, { "property": "fontSize", "token": "tokens.fontSizeBase300", - "path": [ - "fontSize" - ] + "path": ["fontSize"] }, { "property": "fontWeight", "token": "tokens.fontWeightSemibold", - "path": [ - "fontWeight" - ] + "path": ["fontWeight"] }, { "property": "lineHeight", "token": "tokens.lineHeightBase300", - "path": [ - "lineHeight" - ] + "path": ["lineHeight"] }, { "property": "transitionDuration", "token": "tokens.durationFaster", - "path": [ - "transitionDuration" - ] + "path": ["transitionDuration"] }, { "property": "transitionTimingFunction", "token": "tokens.curveEasyEase", - "path": [ - "transitionTimingFunction" - ] + "path": ["transitionTimingFunction"] } ], "nested": { @@ -106,12 +103,19 @@ "path": [] } ] + }, + "'@supports (-moz-appearance:button)'": { + "tokens": [ + { + "property": "boxShadow", + "token": "tokens.colorStrokeFocus2", + "path": [] + } + ] } }, "isResetStyles": true, - "assignedVariables": [ - "rootBaseClassName" - ] + "assignedVariables": ["rootBaseClassName"] } }, "useIconBaseClassName": { @@ -120,16 +124,12 @@ { "property": "[iconSpacingVar]", "token": "tokens.spacingHorizontalSNudge", - "path": [ - "[iconSpacingVar]" - ] + "path": ["[iconSpacingVar]"] } ], "nested": {}, "isResetStyles": true, - "assignedVariables": [ - "iconBaseClassName" - ] + "assignedVariables": ["iconBaseClassName"] } }, "useRootStyles": { @@ -138,9 +138,7 @@ { "property": "backgroundColor", "token": "tokens.colorTransparentBackground", - "path": [ - "backgroundColor" - ] + "path": ["backgroundColor"] } ], "nested": { @@ -163,25 +161,19 @@ ] } }, - "assignedVariables": [ - "rootStyles" - ] + "assignedVariables": ["rootStyles"] }, "primary": { "tokens": [ { "property": "backgroundColor", "token": "tokens.colorBrandBackground", - "path": [ - "backgroundColor" - ] + "path": ["backgroundColor"] }, { "property": "color", "token": "tokens.colorNeutralForegroundOnBrand", - "path": [ - "color" - ] + "path": ["color"] } ], "nested": { @@ -214,25 +206,19 @@ ] } }, - "assignedVariables": [ - "rootStyles" - ] + "assignedVariables": ["rootStyles"] }, "subtle": { "tokens": [ { "property": "backgroundColor", "token": "tokens.colorSubtleBackground", - "path": [ - "backgroundColor" - ] + "path": ["backgroundColor"] }, { "property": "color", "token": "tokens.colorNeutralForeground2", - "path": [ - "color" - ] + "path": ["color"] } ], "nested": { @@ -275,25 +261,19 @@ ] } }, - "assignedVariables": [ - "rootStyles" - ] + "assignedVariables": ["rootStyles"] }, "transparent": { "tokens": [ { "property": "backgroundColor", "token": "tokens.colorTransparentBackground", - "path": [ - "backgroundColor" - ] + "path": ["backgroundColor"] }, { "property": "color", "token": "tokens.colorNeutralForeground2", - "path": [ - "color" - ] + "path": ["color"] } ], "nested": { @@ -340,107 +320,87 @@ ] } }, - "assignedVariables": [ - "rootStyles" - ] + "assignedVariables": ["rootStyles"] }, "circular": { "tokens": [ { "property": "borderRadius", "token": "tokens.borderRadiusCircular", - "path": [ - "borderRadius" - ] + "path": ["borderRadius"] } ], - "assignedVariables": [ - "rootStyles" - ] + "assignedVariables": ["rootStyles"] }, "square": { "tokens": [ { "property": "borderRadius", "token": "tokens.borderRadiusNone", - "path": [ - "borderRadius" - ] + "path": ["borderRadius"] } ], - "assignedVariables": [ - "rootStyles" - ] + "assignedVariables": ["rootStyles"] }, "small": { "tokens": [ + { + "property": "padding", + "token": "tokens.spacingHorizontalS", + "path": ["padding"] + }, { "property": "borderRadius", "token": "tokens.borderRadiusMedium", - "path": [ - "borderRadius" - ] + "path": ["borderRadius"] }, { "property": "fontSize", "token": "tokens.fontSizeBase200", - "path": [ - "fontSize" - ] + "path": ["fontSize"] }, { "property": "fontWeight", "token": "tokens.fontWeightRegular", - "path": [ - "fontWeight" - ] + "path": ["fontWeight"] }, { "property": "lineHeight", "token": "tokens.lineHeightBase200", - "path": [ - "lineHeight" - ] + "path": ["lineHeight"] } ], - "assignedVariables": [ - "rootStyles" - ] + "assignedVariables": ["rootStyles"] }, "large": { "tokens": [ + { + "property": "padding", + "token": "tokens.spacingHorizontalL", + "path": ["padding"] + }, { "property": "borderRadius", "token": "tokens.borderRadiusMedium", - "path": [ - "borderRadius" - ] + "path": ["borderRadius"] }, { "property": "fontSize", "token": "tokens.fontSizeBase400", - "path": [ - "fontSize" - ] + "path": ["fontSize"] }, { "property": "fontWeight", "token": "tokens.fontWeightSemibold", - "path": [ - "fontWeight" - ] + "path": ["fontWeight"] }, { "property": "lineHeight", "token": "tokens.lineHeightBase400", - "path": [ - "lineHeight" - ] + "path": ["lineHeight"] } ], - "assignedVariables": [ - "rootStyles" - ] + "assignedVariables": ["rootStyles"] } }, "useRootDisabledStyles": { @@ -449,16 +409,32 @@ { "property": "backgroundColor", "token": "tokens.colorNeutralBackgroundDisabled", - "path": [ - "backgroundColor" - ] + "path": ["backgroundColor"] + }, + { + "property": "borderTopColor", + "token": "tokens.colorNeutralStrokeDisabled", + "path": ["borderTopColor"] + }, + { + "property": "borderRightColor", + "token": "tokens.colorNeutralStrokeDisabled", + "path": ["borderRightColor"] + }, + { + "property": "borderBottomColor", + "token": "tokens.colorNeutralStrokeDisabled", + "path": ["borderBottomColor"] + }, + { + "property": "borderLeftColor", + "token": "tokens.colorNeutralStrokeDisabled", + "path": ["borderLeftColor"] }, { "property": "color", "token": "tokens.colorNeutralForegroundDisabled", - "path": [ - "color" - ] + "path": ["color"] } ], "nested": { @@ -478,6 +454,26 @@ "token": "tokens.colorNeutralBackgroundDisabled", "path": [] }, + { + "property": "borderTopColor", + "token": "tokens.colorNeutralStrokeDisabled", + "path": [] + }, + { + "property": "borderRightColor", + "token": "tokens.colorNeutralStrokeDisabled", + "path": [] + }, + { + "property": "borderBottomColor", + "token": "tokens.colorNeutralStrokeDisabled", + "path": [] + }, + { + "property": "borderLeftColor", + "token": "tokens.colorNeutralStrokeDisabled", + "path": [] + }, { "property": "color", "token": "tokens.colorNeutralForegroundDisabled", @@ -497,6 +493,26 @@ "token": "tokens.colorNeutralBackgroundDisabled", "path": [] }, + { + "property": "borderTopColor", + "token": "tokens.colorNeutralStrokeDisabled", + "path": [] + }, + { + "property": "borderRightColor", + "token": "tokens.colorNeutralStrokeDisabled", + "path": [] + }, + { + "property": "borderBottomColor", + "token": "tokens.colorNeutralStrokeDisabled", + "path": [] + }, + { + "property": "borderLeftColor", + "token": "tokens.colorNeutralStrokeDisabled", + "path": [] + }, { "property": "color", "token": "tokens.colorNeutralForegroundDisabled", @@ -510,18 +526,14 @@ ] } }, - "assignedVariables": [ - "rootDisabledStyles" - ] + "assignedVariables": ["rootDisabledStyles"] }, "outline": { "tokens": [ { "property": "backgroundColor", "token": "tokens.colorTransparentBackground", - "path": [ - "backgroundColor" - ] + "path": ["backgroundColor"] } ], "nested": { @@ -544,18 +556,14 @@ ] } }, - "assignedVariables": [ - "rootDisabledStyles" - ] + "assignedVariables": ["rootDisabledStyles"] }, "subtle": { "tokens": [ { "property": "backgroundColor", "token": "tokens.colorTransparentBackground", - "path": [ - "backgroundColor" - ] + "path": ["backgroundColor"] } ], "nested": { @@ -578,18 +586,14 @@ ] } }, - "assignedVariables": [ - "rootDisabledStyles" - ] + "assignedVariables": ["rootDisabledStyles"] }, "transparent": { "tokens": [ { "property": "backgroundColor", "token": "tokens.colorTransparentBackground", - "path": [ - "backgroundColor" - ] + "path": ["backgroundColor"] } ], "nested": { @@ -612,9 +616,7 @@ ] } }, - "assignedVariables": [ - "rootDisabledStyles" - ] + "assignedVariables": ["rootDisabledStyles"] } }, "useRootFocusStyles": { @@ -631,9 +633,7 @@ ] } }, - "assignedVariables": [ - "rootFocusStyles" - ] + "assignedVariables": ["rootFocusStyles"] }, "square": { "tokens": [], @@ -648,9 +648,111 @@ ] } }, - "assignedVariables": [ - "rootFocusStyles" - ] + "assignedVariables": ["rootFocusStyles"] + }, + "primary": { + "tokens": [], + "nested": { + ":focus": { + "tokens": [ + { + "property": "boxShadow", + "token": "tokens.shadow2", + "path": [] + }, + { + "property": "boxShadow", + "token": "tokens.strokeWidthThin", + "path": [] + }, + { + "property": "boxShadow", + "token": "tokens.colorStrokeFocus2", + "path": [] + }, + { + "property": "boxShadow", + "token": "tokens.strokeWidthThick", + "path": [] + }, + { + "property": "boxShadow", + "token": "tokens.colorNeutralForegroundOnBrand", + "path": [] + }, + { + "property": "boxShadow", + "token": "tokens.shadow2", + "path": [] + }, + { + "property": "boxShadow", + "token": "tokens.strokeWidthThin", + "path": [] + }, + { + "property": "boxShadow", + "token": "tokens.colorStrokeFocus2", + "path": [] + }, + { + "property": "borderTopColor", + "token": "tokens.colorStrokeFocus2", + "path": [] + }, + { + "property": "borderRightColor", + "token": "tokens.colorStrokeFocus2", + "path": [] + }, + { + "property": "borderBottomColor", + "token": "tokens.colorStrokeFocus2", + "path": [] + }, + { + "property": "borderLeftColor", + "token": "tokens.colorStrokeFocus2", + "path": [] + } + ] + }, + "'@supports (-moz-appearance:button)'": { + "tokens": [ + { + "property": "boxShadow", + "token": "tokens.shadow2", + "path": [] + }, + { + "property": "boxShadow", + "token": "tokens.colorStrokeFocus2", + "path": [] + }, + { + "property": "boxShadow", + "token": "tokens.strokeWidthThick", + "path": [] + }, + { + "property": "boxShadow", + "token": "tokens.colorNeutralForegroundOnBrand", + "path": [] + }, + { + "property": "boxShadow", + "token": "tokens.shadow2", + "path": [] + }, + { + "property": "boxShadow", + "token": "tokens.colorStrokeFocus2", + "path": [] + } + ] + } + }, + "assignedVariables": ["rootFocusStyles"] }, "small": { "tokens": [], @@ -665,9 +767,7 @@ ] } }, - "assignedVariables": [ - "rootFocusStyles" - ] + "assignedVariables": ["rootFocusStyles"] }, "large": { "tokens": [], @@ -682,9 +782,7 @@ ] } }, - "assignedVariables": [ - "rootFocusStyles" - ] + "assignedVariables": ["rootFocusStyles"] } }, "useRootIconOnlyStyles": {}, @@ -694,28 +792,20 @@ { "property": "[iconSpacingVar]", "token": "tokens.spacingHorizontalXS", - "path": [ - "[iconSpacingVar]" - ] + "path": ["[iconSpacingVar]"] } ], - "assignedVariables": [ - "iconStyles" - ] + "assignedVariables": ["iconStyles"] }, "large": { "tokens": [ { "property": "[iconSpacingVar]", "token": "tokens.spacingHorizontalSNudge", - "path": [ - "[iconSpacingVar]" - ] + "path": ["[iconSpacingVar]"] } ], - "assignedVariables": [ - "iconStyles" - ] + "assignedVariables": ["iconStyles"] } } }, @@ -750,33 +840,23 @@ "slotName": "root" }, "rootStyles.smallWithIcon": { - "conditions": [ - "icon && size === 'small'" - ], + "conditions": ["icon && size === 'small'"], "slotName": "root" }, "rootStyles.largeWithIcon": { - "conditions": [ - "icon && size === 'large'" - ], + "conditions": ["icon && size === 'large'"], "slotName": "root" }, "rootDisabledStyles.base": { - "conditions": [ - "(disabled || disabledFocusable)" - ], + "conditions": ["(disabled || disabledFocusable)"], "slotName": "root" }, "rootDisabledStyles.highContrast": { - "conditions": [ - "(disabled || disabledFocusable)" - ], + "conditions": ["(disabled || disabledFocusable)"], "slotName": "root" }, "rootFocusStyles.primary": { - "conditions": [ - "appearance === 'primary'" - ], + "conditions": ["appearance === 'primary'"], "slotName": "root" }, "buttonClassNames.icon": { @@ -798,341 +878,74 @@ } } }, - "library/src/components/MenuButton/useMenuButtonStyles.styles.ts": { + "library/src/components/CompoundButton/useCompoundButtonStyles.styles.ts": { "styles": { - "useRootExpandedStyles": { - "outline": { - "tokens": [ - { - "property": "color", - "token": "tokens.colorNeutralForeground1Selected", - "path": [ - "color" + "useRootStyles": { + "base": { + "tokens": [], + "nested": { + "[`& .${compoundButtonClassNames.secondaryContent}`]": { + "tokens": [ + { + "property": "color", + "token": "tokens.colorNeutralForeground2", + "path": [] + } + ] + }, + "':hover'": { + "tokens": [ + { + "property": "color", + "token": "tokens.colorNeutralForeground2Hover", + "path": [] + } + ] + }, + "':hover:active'": { + "tokens": [ + { + "property": "color", + "token": "tokens.colorNeutralForeground2Pressed", + "path": [] + } ] } - ], - "assignedVariables": [ - "rootExpandedStyles" - ] + }, + "assignedVariables": ["rootStyles"] }, "primary": { - "tokens": [ - { - "property": "backgroundColor", - "token": "tokens.colorBrandBackgroundSelected", - "path": [ - "backgroundColor" + "tokens": [], + "nested": { + "[`& .${compoundButtonClassNames.secondaryContent}`]": { + "tokens": [ + { + "property": "color", + "token": "tokens.colorNeutralForegroundOnBrand", + "path": [] + } ] - } - ], - "assignedVariables": [ - "rootExpandedStyles" - ] - }, - "secondary": { - "tokens": [ - { - "property": "backgroundColor", - "token": "tokens.colorNeutralBackground1Selected", - "path": [ - "backgroundColor" + }, + "':hover'": { + "tokens": [ + { + "property": "color", + "token": "tokens.colorNeutralForegroundOnBrand", + "path": [] + } ] }, - { - "property": "color", - "token": "tokens.colorNeutralForeground1Selected", - "path": [ - "color" + "':hover:active'": { + "tokens": [ + { + "property": "color", + "token": "tokens.colorNeutralForegroundOnBrand", + "path": [] + } ] } - ], - "assignedVariables": [ - "rootExpandedStyles" - ] - }, - "subtle": { - "tokens": [ - { - "property": "backgroundColor", - "token": "tokens.colorSubtleBackgroundSelected", - "path": [ - "backgroundColor" - ] - }, - { - "property": "color", - "token": "tokens.colorNeutralForeground2Selected", - "path": [ - "color" - ] - } - ], - "assignedVariables": [ - "rootExpandedStyles" - ] - }, - "transparent": { - "tokens": [ - { - "property": "backgroundColor", - "token": "tokens.colorTransparentBackgroundSelected", - "path": [ - "backgroundColor" - ] - }, - { - "property": "color", - "token": "tokens.colorNeutralForeground2BrandSelected", - "path": [ - "color" - ] - } - ], - "assignedVariables": [ - "rootExpandedStyles" - ] - } - }, - "useIconExpandedStyles": { - "outline": { - "tokens": [ - { - "property": "color", - "token": "tokens.colorNeutralForeground1Selected", - "path": [ - "color" - ] - } - ], - "assignedVariables": [ - "iconExpandedStyles" - ] - }, - "secondary": { - "tokens": [ - { - "property": "color", - "token": "tokens.colorNeutralForeground1Selected", - "path": [ - "color" - ] - } - ], - "assignedVariables": [ - "iconExpandedStyles" - ] - }, - "subtle": { - "tokens": [ - { - "property": "color", - "token": "tokens.colorNeutralForeground2BrandSelected", - "path": [ - "color" - ] - } - ], - "assignedVariables": [ - "iconExpandedStyles" - ] - }, - "transparent": { - "tokens": [ - { - "property": "color", - "token": "tokens.colorNeutralForeground2BrandSelected", - "path": [ - "color" - ] - } - ], - "assignedVariables": [ - "iconExpandedStyles" - ] - } - }, - "useMenuIconStyles": { - "small": { - "tokens": [ - { - "property": "lineHeight", - "token": "tokens.lineHeightBase200", - "path": [ - "lineHeight" - ] - } - ], - "assignedVariables": [ - "menuIconStyles" - ] - }, - "medium": { - "tokens": [ - { - "property": "lineHeight", - "token": "tokens.lineHeightBase200", - "path": [ - "lineHeight" - ] - } - ], - "assignedVariables": [ - "menuIconStyles" - ] - }, - "large": { - "tokens": [ - { - "property": "lineHeight", - "token": "tokens.lineHeightBase400", - "path": [ - "lineHeight" - ] - } - ], - "assignedVariables": [ - "menuIconStyles" - ] - }, - "notIconOnly": { - "tokens": [ - { - "property": "marginLeft", - "token": "tokens.spacingHorizontalXS", - "path": [ - "marginLeft" - ] - } - ], - "assignedVariables": [ - "menuIconStyles" - ] - } - } - }, - "metadata": { - "styleConditions": { - "menuButtonClassNames.root": { - "isBase": true, - "slotName": "root" - }, - "state.root.className": { - "isBase": true, - "slotName": "root" - }, - "rootExpandedStyles.base": { - "conditions": [ - "state.root['aria-expanded']" - ], - "slotName": "root" - }, - "menuButtonClassNames.icon": { - "isBase": true, - "slotName": "icon" - }, - "state.icon.className": { - "isBase": true, - "slotName": "icon" - }, - "iconExpandedStyles.highContrast": { - "conditions": [ - "state.root['aria-expanded'] && iconExpandedStyles[state.appearance]" - ], - "slotName": "icon" - }, - "menuButtonClassNames.menuIcon": { - "isBase": true, - "slotName": "menuIcon" - }, - "menuIconStyles.base": { - "isBase": true, - "slotName": "menuIcon" - }, - "state.menuIcon.className": { - "isBase": true, - "slotName": "menuIcon" - }, - "menuIconStyles.notIconOnly": { - "conditions": [ - "!state.iconOnly" - ], - "slotName": "menuIcon" - } - } - } - }, - "library/src/components/CompoundButton/useCompoundButtonStyles.styles.ts": { - "styles": { - "useRootStyles": { - "base": { - "tokens": [], - "nested": { - "[`& .${compoundButtonClassNames.secondaryContent}`]": { - "tokens": [ - { - "property": "color", - "token": "tokens.colorNeutralForeground2", - "path": [] - } - ] - }, - "':hover'": { - "tokens": [ - { - "property": "color", - "token": "tokens.colorNeutralForeground2Hover", - "path": [] - } - ] - }, - "':hover:active'": { - "tokens": [ - { - "property": "color", - "token": "tokens.colorNeutralForeground2Pressed", - "path": [] - } - ] - } - }, - "assignedVariables": [ - "rootStyles" - ] - }, - "primary": { - "tokens": [], - "nested": { - "[`& .${compoundButtonClassNames.secondaryContent}`]": { - "tokens": [ - { - "property": "color", - "token": "tokens.colorNeutralForegroundOnBrand", - "path": [] - } - ] - }, - "':hover'": { - "tokens": [ - { - "property": "color", - "token": "tokens.colorNeutralForegroundOnBrand", - "path": [] - } - ] - }, - "':hover:active'": { - "tokens": [ - { - "property": "color", - "token": "tokens.colorNeutralForegroundOnBrand", - "path": [] - } - ] - } - }, - "assignedVariables": [ - "rootStyles" - ] + }, + "assignedVariables": ["rootStyles"] }, "subtle": { "tokens": [], @@ -1165,9 +978,7 @@ ] } }, - "assignedVariables": [ - "rootStyles" - ] + "assignedVariables": ["rootStyles"] }, "transparent": { "tokens": [], @@ -1200,72 +1011,102 @@ ] } }, - "assignedVariables": [ - "rootStyles" - ] + "assignedVariables": ["rootStyles"] }, "small": { "tokens": [ + { + "property": "padding", + "token": "tokens.spacingHorizontalS", + "path": ["padding"] + }, + { + "property": "padding", + "token": "tokens.spacingHorizontalS", + "path": ["padding"] + }, + { + "property": "padding", + "token": "tokens.spacingHorizontalMNudge", + "path": ["padding"] + }, + { + "property": "padding", + "token": "tokens.spacingHorizontalS", + "path": ["padding"] + }, { "property": "fontSize", "token": "tokens.fontSizeBase300", - "path": [ - "fontSize" - ] + "path": ["fontSize"] }, { "property": "lineHeight", "token": "tokens.lineHeightBase300", - "path": [ - "lineHeight" - ] + "path": ["lineHeight"] } ], - "assignedVariables": [ - "rootStyles" - ] + "assignedVariables": ["rootStyles"] }, "medium": { "tokens": [ + { + "property": "padding", + "token": "tokens.spacingHorizontalM", + "path": ["padding"] + }, + { + "property": "padding", + "token": "tokens.spacingHorizontalL", + "path": ["padding"] + }, + { + "property": "padding", + "token": "tokens.spacingHorizontalM", + "path": ["padding"] + }, { "property": "fontSize", "token": "tokens.fontSizeBase300", - "path": [ - "fontSize" - ] + "path": ["fontSize"] }, { "property": "lineHeight", "token": "tokens.lineHeightBase300", - "path": [ - "lineHeight" - ] + "path": ["lineHeight"] } ], - "assignedVariables": [ - "rootStyles" - ] + "assignedVariables": ["rootStyles"] }, "large": { "tokens": [ + { + "property": "padding", + "token": "tokens.spacingHorizontalL", + "path": ["padding"] + }, + { + "property": "padding", + "token": "tokens.spacingHorizontalXL", + "path": ["padding"] + }, + { + "property": "padding", + "token": "tokens.spacingHorizontalL", + "path": ["padding"] + }, { "property": "fontSize", "token": "tokens.fontSizeBase400", - "path": [ - "fontSize" - ] + "path": ["fontSize"] }, { "property": "lineHeight", "token": "tokens.lineHeightBase400", - "path": [ - "lineHeight" - ] + "path": ["lineHeight"] } ], - "assignedVariables": [ - "rootStyles" - ] + "assignedVariables": ["rootStyles"] }, "disabled": { "tokens": [], @@ -1298,9 +1139,7 @@ ] } }, - "assignedVariables": [ - "rootStyles" - ] + "assignedVariables": ["rootStyles"] } }, "useRootIconOnlyStyles": { @@ -1309,149 +1148,368 @@ { "property": "padding", "token": "tokens.spacingHorizontalXS", - "path": [ - "padding" - ] + "path": ["padding"] + } + ], + "assignedVariables": ["rootIconOnlyStyles"] + }, + "medium": { + "tokens": [ + { + "property": "padding", + "token": "tokens.spacingHorizontalSNudge", + "path": ["padding"] + } + ], + "assignedVariables": ["rootIconOnlyStyles"] + }, + "large": { + "tokens": [ + { + "property": "padding", + "token": "tokens.spacingHorizontalS", + "path": ["padding"] + } + ], + "assignedVariables": ["rootIconOnlyStyles"] + } + }, + "useIconStyles": { + "before": { + "tokens": [ + { + "property": "marginRight", + "token": "tokens.spacingHorizontalM", + "path": ["marginRight"] + } + ], + "assignedVariables": ["iconStyles"] + }, + "after": { + "tokens": [ + { + "property": "marginLeft", + "token": "tokens.spacingHorizontalM", + "path": ["marginLeft"] + } + ], + "assignedVariables": ["iconStyles"] + } + }, + "useContentContainerStyles": {}, + "useSecondaryContentStyles": { + "base": { + "tokens": [ + { + "property": "fontWeight", + "token": "tokens.fontWeightRegular", + "path": ["fontWeight"] + } + ], + "assignedVariables": ["secondaryContentStyles"] + }, + "small": { + "tokens": [ + { + "property": "fontSize", + "token": "tokens.fontSizeBase200", + "path": ["fontSize"] + } + ], + "assignedVariables": ["secondaryContentStyles"] + }, + "medium": { + "tokens": [ + { + "property": "fontSize", + "token": "tokens.fontSizeBase200", + "path": ["fontSize"] + } + ], + "assignedVariables": ["secondaryContentStyles"] + }, + "large": { + "tokens": [ + { + "property": "fontSize", + "token": "tokens.fontSizeBase300", + "path": ["fontSize"] + } + ], + "assignedVariables": ["secondaryContentStyles"] + } + } + }, + "metadata": { + "styleConditions": { + "compoundButtonClassNames.root": { + "isBase": true, + "slotName": "root" + }, + "rootStyles.base": { + "isBase": true, + "slotName": "root" + }, + "rootStyles.highContrast": { + "isBase": true, + "slotName": "root" + }, + "rootStyles[size]": { + "isBase": true, + "slotName": "root" + }, + "state.root.className": { + "isBase": true, + "slotName": "root" + }, + "rootStyles.disabled": { + "conditions": ["(disabled || disabledFocusable)"], + "slotName": "root" + }, + "rootStyles.disabledHighContrast": { + "conditions": ["(disabled || disabledFocusable)"], + "slotName": "root" + }, + "compoundButtonClassNames.contentContainer": { + "isBase": true, + "slotName": "contentContainer" + }, + "contentContainerStyles.base": { + "isBase": true, + "slotName": "contentContainer" + }, + "state.contentContainer.className": { + "isBase": true, + "slotName": "contentContainer" + }, + "compoundButtonClassNames.icon": { + "isBase": true, + "slotName": "icon" + }, + "iconStyles.base": { + "isBase": true, + "slotName": "icon" + }, + "state.icon.className": { + "isBase": true, + "slotName": "icon" + }, + "compoundButtonClassNames.secondaryContent": { + "isBase": true, + "slotName": "secondaryContent" + }, + "secondaryContentStyles.base": { + "isBase": true, + "slotName": "secondaryContent" + }, + "secondaryContentStyles[size]": { + "isBase": true, + "slotName": "secondaryContent" + }, + "state.secondaryContent.className": { + "isBase": true, + "slotName": "secondaryContent" + } + } + } + }, + "library/src/components/MenuButton/useMenuButtonStyles.styles.ts": { + "styles": { + "useRootExpandedStyles": { + "outline": { + "tokens": [ + { + "property": "borderTopColor", + "token": "tokens.colorNeutralStroke1Selected", + "path": ["borderTopColor"] + }, + { + "property": "borderRightColor", + "token": "tokens.colorNeutralStroke1Selected", + "path": ["borderRightColor"] + }, + { + "property": "borderBottomColor", + "token": "tokens.colorNeutralStroke1Selected", + "path": ["borderBottomColor"] + }, + { + "property": "borderLeftColor", + "token": "tokens.colorNeutralStroke1Selected", + "path": ["borderLeftColor"] + }, + { + "property": "color", + "token": "tokens.colorNeutralForeground1Selected", + "path": ["color"] + } + ], + "assignedVariables": ["rootExpandedStyles"] + }, + "primary": { + "tokens": [ + { + "property": "backgroundColor", + "token": "tokens.colorBrandBackgroundSelected", + "path": ["backgroundColor"] + } + ], + "assignedVariables": ["rootExpandedStyles"] + }, + "secondary": { + "tokens": [ + { + "property": "backgroundColor", + "token": "tokens.colorNeutralBackground1Selected", + "path": ["backgroundColor"] + }, + { + "property": "borderTopColor", + "token": "tokens.colorNeutralStroke1Selected", + "path": ["borderTopColor"] + }, + { + "property": "borderRightColor", + "token": "tokens.colorNeutralStroke1Selected", + "path": ["borderRightColor"] + }, + { + "property": "borderBottomColor", + "token": "tokens.colorNeutralStroke1Selected", + "path": ["borderBottomColor"] + }, + { + "property": "borderLeftColor", + "token": "tokens.colorNeutralStroke1Selected", + "path": ["borderLeftColor"] + }, + { + "property": "color", + "token": "tokens.colorNeutralForeground1Selected", + "path": ["color"] } ], - "assignedVariables": [ - "rootIconOnlyStyles" - ] + "assignedVariables": ["rootExpandedStyles"] }, - "medium": { + "subtle": { "tokens": [ { - "property": "padding", - "token": "tokens.spacingHorizontalSNudge", - "path": [ - "padding" - ] + "property": "backgroundColor", + "token": "tokens.colorSubtleBackgroundSelected", + "path": ["backgroundColor"] + }, + { + "property": "color", + "token": "tokens.colorNeutralForeground2Selected", + "path": ["color"] } ], - "assignedVariables": [ - "rootIconOnlyStyles" - ] + "assignedVariables": ["rootExpandedStyles"] }, - "large": { + "transparent": { "tokens": [ { - "property": "padding", - "token": "tokens.spacingHorizontalS", - "path": [ - "padding" - ] + "property": "backgroundColor", + "token": "tokens.colorTransparentBackgroundSelected", + "path": ["backgroundColor"] + }, + { + "property": "color", + "token": "tokens.colorNeutralForeground2BrandSelected", + "path": ["color"] } ], - "assignedVariables": [ - "rootIconOnlyStyles" - ] + "assignedVariables": ["rootExpandedStyles"] } }, - "useIconStyles": { - "before": { + "useIconExpandedStyles": { + "outline": { "tokens": [ { - "property": "marginRight", - "token": "tokens.spacingHorizontalM", - "path": [ - "marginRight" - ] + "property": "color", + "token": "tokens.colorNeutralForeground1Selected", + "path": ["color"] } ], - "assignedVariables": [ - "iconStyles" - ] + "assignedVariables": ["iconExpandedStyles"] }, - "after": { + "secondary": { "tokens": [ { - "property": "marginLeft", - "token": "tokens.spacingHorizontalM", - "path": [ - "marginLeft" - ] + "property": "color", + "token": "tokens.colorNeutralForeground1Selected", + "path": ["color"] } ], - "assignedVariables": [ - "iconStyles" - ] - } - }, - "useContentContainerStyles": {}, - "useSecondaryContentStyles": { - "base": { + "assignedVariables": ["iconExpandedStyles"] + }, + "subtle": { "tokens": [ { - "property": "fontWeight", - "token": "tokens.fontWeightRegular", - "path": [ - "fontWeight" - ] + "property": "color", + "token": "tokens.colorNeutralForeground2BrandSelected", + "path": ["color"] } ], - "assignedVariables": [ - "secondaryContentStyles" - ] + "assignedVariables": ["iconExpandedStyles"] }, + "transparent": { + "tokens": [ + { + "property": "color", + "token": "tokens.colorNeutralForeground2BrandSelected", + "path": ["color"] + } + ], + "assignedVariables": ["iconExpandedStyles"] + } + }, + "useMenuIconStyles": { "small": { "tokens": [ { - "property": "fontSize", - "token": "tokens.fontSizeBase200", - "path": [ - "fontSize" - ] + "property": "lineHeight", + "token": "tokens.lineHeightBase200", + "path": ["lineHeight"] } ], - "assignedVariables": [ - "secondaryContentStyles" - ] + "assignedVariables": ["menuIconStyles"] }, "medium": { "tokens": [ { - "property": "fontSize", - "token": "tokens.fontSizeBase200", - "path": [ - "fontSize" - ] + "property": "lineHeight", + "token": "tokens.lineHeightBase200", + "path": ["lineHeight"] } ], - "assignedVariables": [ - "secondaryContentStyles" - ] + "assignedVariables": ["menuIconStyles"] }, "large": { "tokens": [ { - "property": "fontSize", - "token": "tokens.fontSizeBase300", - "path": [ - "fontSize" - ] + "property": "lineHeight", + "token": "tokens.lineHeightBase400", + "path": ["lineHeight"] + } + ], + "assignedVariables": ["menuIconStyles"] + }, + "notIconOnly": { + "tokens": [ + { + "property": "marginLeft", + "token": "tokens.spacingHorizontalXS", + "path": ["marginLeft"] } ], - "assignedVariables": [ - "secondaryContentStyles" - ] + "assignedVariables": ["menuIconStyles"] } } }, "metadata": { "styleConditions": { - "compoundButtonClassNames.root": { - "isBase": true, - "slotName": "root" - }, - "rootStyles.base": { - "isBase": true, - "slotName": "root" - }, - "rootStyles.highContrast": { - "isBase": true, - "slotName": "root" - }, - "rootStyles[size]": { + "menuButtonClassNames.root": { "isBase": true, "slotName": "root" }, @@ -1459,57 +1517,37 @@ "isBase": true, "slotName": "root" }, - "rootStyles.disabled": { - "conditions": [ - "(disabled || disabledFocusable)" - ], - "slotName": "root" - }, - "rootStyles.disabledHighContrast": { - "conditions": [ - "(disabled || disabledFocusable)" - ], + "rootExpandedStyles.base": { + "conditions": ["state.root['aria-expanded']"], "slotName": "root" }, - "compoundButtonClassNames.contentContainer": { - "isBase": true, - "slotName": "contentContainer" - }, - "contentContainerStyles.base": { - "isBase": true, - "slotName": "contentContainer" - }, - "state.contentContainer.className": { - "isBase": true, - "slotName": "contentContainer" - }, - "compoundButtonClassNames.icon": { + "menuButtonClassNames.icon": { "isBase": true, "slotName": "icon" }, - "iconStyles.base": { + "state.icon.className": { "isBase": true, "slotName": "icon" }, - "state.icon.className": { - "isBase": true, + "iconExpandedStyles.highContrast": { + "conditions": ["state.root['aria-expanded'] && iconExpandedStyles[state.appearance]"], "slotName": "icon" }, - "compoundButtonClassNames.secondaryContent": { + "menuButtonClassNames.menuIcon": { "isBase": true, - "slotName": "secondaryContent" + "slotName": "menuIcon" }, - "secondaryContentStyles.base": { + "menuIconStyles.base": { "isBase": true, - "slotName": "secondaryContent" + "slotName": "menuIcon" }, - "secondaryContentStyles[size]": { + "state.menuIcon.className": { "isBase": true, - "slotName": "secondaryContent" + "slotName": "menuIcon" }, - "state.secondaryContent.className": { - "isBase": true, - "slotName": "secondaryContent" + "menuIconStyles.notIconOnly": { + "conditions": ["!state.iconOnly"], + "slotName": "menuIcon" } } } @@ -1549,9 +1587,7 @@ ] } }, - "assignedVariables": [ - "rootStyles" - ] + "assignedVariables": ["rootStyles"] }, "subtle": { "tokens": [], @@ -1584,9 +1620,7 @@ ] } }, - "assignedVariables": [ - "rootStyles" - ] + "assignedVariables": ["rootStyles"] }, "transparent": { "tokens": [], @@ -1619,9 +1653,7 @@ ] } }, - "assignedVariables": [ - "rootStyles" - ] + "assignedVariables": ["rootStyles"] }, "disabled": { "tokens": [], @@ -1654,9 +1686,7 @@ ] } }, - "assignedVariables": [ - "rootStyles" - ] + "assignedVariables": ["rootStyles"] } } }, @@ -1675,15 +1705,11 @@ "slotName": "root" }, "rootStyles.disabled": { - "conditions": [ - "(disabled || disabledFocusable)" - ], + "conditions": ["(disabled || disabledFocusable)"], "slotName": "root" }, "rootStyles.disabledHighContrast": { - "conditions": [ - "(disabled || disabledFocusable)" - ], + "conditions": ["(disabled || disabledFocusable)"], "slotName": "root" }, "splitButtonClassNames.menuButton": { @@ -1721,16 +1747,32 @@ { "property": "backgroundColor", "token": "tokens.colorNeutralBackground1Selected", - "path": [ - "backgroundColor" - ] + "path": ["backgroundColor"] + }, + { + "property": "borderTopColor", + "token": "tokens.colorNeutralStroke1", + "path": ["borderTopColor"] + }, + { + "property": "borderRightColor", + "token": "tokens.colorNeutralStroke1", + "path": ["borderRightColor"] + }, + { + "property": "borderBottomColor", + "token": "tokens.colorNeutralStroke1", + "path": ["borderBottomColor"] + }, + { + "property": "borderLeftColor", + "token": "tokens.colorNeutralStroke1", + "path": ["borderLeftColor"] }, { "property": "color", "token": "tokens.colorNeutralForeground1Selected", - "path": [ - "color" - ] + "path": ["color"] } ], "nested": { @@ -1741,6 +1783,26 @@ "token": "tokens.colorNeutralBackground1Hover", "path": [] }, + { + "property": "borderTopColor", + "token": "tokens.colorNeutralStroke1Hover", + "path": [] + }, + { + "property": "borderRightColor", + "token": "tokens.colorNeutralStroke1Hover", + "path": [] + }, + { + "property": "borderBottomColor", + "token": "tokens.colorNeutralStroke1Hover", + "path": [] + }, + { + "property": "borderLeftColor", + "token": "tokens.colorNeutralStroke1Hover", + "path": [] + }, { "property": "color", "token": "tokens.colorNeutralForeground1Hover", @@ -1755,6 +1817,26 @@ "token": "tokens.colorNeutralBackground1Pressed", "path": [] }, + { + "property": "borderTopColor", + "token": "tokens.colorNeutralStroke1Pressed", + "path": [] + }, + { + "property": "borderRightColor", + "token": "tokens.colorNeutralStroke1Pressed", + "path": [] + }, + { + "property": "borderBottomColor", + "token": "tokens.colorNeutralStroke1Pressed", + "path": [] + }, + { + "property": "borderLeftColor", + "token": "tokens.colorNeutralStroke1Pressed", + "path": [] + }, { "property": "color", "token": "tokens.colorNeutralForeground1Pressed", @@ -1763,18 +1845,34 @@ ] } }, - "assignedVariables": [ - "rootCheckedStyles" - ] + "assignedVariables": ["rootCheckedStyles"] }, "outline": { "tokens": [ { "property": "backgroundColor", "token": "tokens.colorTransparentBackgroundSelected", - "path": [ - "backgroundColor" - ] + "path": ["backgroundColor"] + }, + { + "property": "borderTopColor", + "token": "tokens.colorNeutralStroke1", + "path": ["borderTopColor"] + }, + { + "property": "borderRightColor", + "token": "tokens.colorNeutralStroke1", + "path": ["borderRightColor"] + }, + { + "property": "borderBottomColor", + "token": "tokens.colorNeutralStroke1", + "path": ["borderBottomColor"] + }, + { + "property": "borderLeftColor", + "token": "tokens.colorNeutralStroke1", + "path": ["borderLeftColor"] } ], "nested": { @@ -1797,25 +1895,19 @@ ] } }, - "assignedVariables": [ - "rootCheckedStyles" - ] + "assignedVariables": ["rootCheckedStyles"] }, "primary": { "tokens": [ { "property": "backgroundColor", "token": "tokens.colorBrandBackgroundSelected", - "path": [ - "backgroundColor" - ] + "path": ["backgroundColor"] }, { "property": "color", "token": "tokens.colorNeutralForegroundOnBrand", - "path": [ - "color" - ] + "path": ["color"] } ], "nested": { @@ -1848,25 +1940,19 @@ ] } }, - "assignedVariables": [ - "rootCheckedStyles" - ] + "assignedVariables": ["rootCheckedStyles"] }, "subtle": { "tokens": [ { "property": "backgroundColor", "token": "tokens.colorSubtleBackgroundSelected", - "path": [ - "backgroundColor" - ] + "path": ["backgroundColor"] }, { "property": "color", "token": "tokens.colorNeutralForeground2Selected", - "path": [ - "color" - ] + "path": ["color"] } ], "nested": { @@ -1899,25 +1985,19 @@ ] } }, - "assignedVariables": [ - "rootCheckedStyles" - ] + "assignedVariables": ["rootCheckedStyles"] }, "transparent": { "tokens": [ { "property": "backgroundColor", "token": "tokens.colorTransparentBackgroundSelected", - "path": [ - "backgroundColor" - ] + "path": ["backgroundColor"] }, { "property": "color", "token": "tokens.colorNeutralForeground2BrandSelected", - "path": [ - "color" - ] + "path": ["color"] } ], "nested": { @@ -1950,9 +2030,7 @@ ] } }, - "assignedVariables": [ - "rootCheckedStyles" - ] + "assignedVariables": ["rootCheckedStyles"] } }, "useRootDisabledStyles": { @@ -1961,16 +2039,32 @@ { "property": "backgroundColor", "token": "tokens.colorNeutralBackgroundDisabled", - "path": [ - "backgroundColor" - ] + "path": ["backgroundColor"] + }, + { + "property": "borderTopColor", + "token": "tokens.colorNeutralStrokeDisabled", + "path": ["borderTopColor"] + }, + { + "property": "borderRightColor", + "token": "tokens.colorNeutralStrokeDisabled", + "path": ["borderRightColor"] + }, + { + "property": "borderBottomColor", + "token": "tokens.colorNeutralStrokeDisabled", + "path": ["borderBottomColor"] + }, + { + "property": "borderLeftColor", + "token": "tokens.colorNeutralStrokeDisabled", + "path": ["borderLeftColor"] }, { "property": "color", "token": "tokens.colorNeutralForegroundDisabled", - "path": [ - "color" - ] + "path": ["color"] } ], "nested": { @@ -1981,6 +2075,26 @@ "token": "tokens.colorNeutralBackgroundDisabled", "path": [] }, + { + "property": "borderTopColor", + "token": "tokens.colorNeutralStrokeDisabled", + "path": [] + }, + { + "property": "borderRightColor", + "token": "tokens.colorNeutralStrokeDisabled", + "path": [] + }, + { + "property": "borderBottomColor", + "token": "tokens.colorNeutralStrokeDisabled", + "path": [] + }, + { + "property": "borderLeftColor", + "token": "tokens.colorNeutralStrokeDisabled", + "path": [] + }, { "property": "color", "token": "tokens.colorNeutralForegroundDisabled", @@ -1995,6 +2109,26 @@ "token": "tokens.colorNeutralBackgroundDisabled", "path": [] }, + { + "property": "borderTopColor", + "token": "tokens.colorNeutralStrokeDisabled", + "path": [] + }, + { + "property": "borderRightColor", + "token": "tokens.colorNeutralStrokeDisabled", + "path": [] + }, + { + "property": "borderBottomColor", + "token": "tokens.colorNeutralStrokeDisabled", + "path": [] + }, + { + "property": "borderLeftColor", + "token": "tokens.colorNeutralStrokeDisabled", + "path": [] + }, { "property": "color", "token": "tokens.colorNeutralForegroundDisabled", @@ -2003,18 +2137,14 @@ ] } }, - "assignedVariables": [ - "rootDisabledStyles" - ] + "assignedVariables": ["rootDisabledStyles"] }, "subtle": { "tokens": [ { "property": "backgroundColor", "token": "tokens.colorTransparentBackground", - "path": [ - "backgroundColor" - ] + "path": ["backgroundColor"] } ], "nested": { @@ -2037,18 +2167,14 @@ ] } }, - "assignedVariables": [ - "rootDisabledStyles" - ] + "assignedVariables": ["rootDisabledStyles"] }, "transparent": { "tokens": [ { "property": "backgroundColor", "token": "tokens.colorTransparentBackground", - "path": [ - "backgroundColor" - ] + "path": ["backgroundColor"] } ], "nested": { @@ -2071,9 +2197,7 @@ ] } }, - "assignedVariables": [ - "rootDisabledStyles" - ] + "assignedVariables": ["rootDisabledStyles"] } }, "useIconCheckedStyles": { @@ -2082,14 +2206,10 @@ { "property": "color", "token": "tokens.colorNeutralForeground2BrandSelected", - "path": [ - "color" - ] + "path": ["color"] } ], - "assignedVariables": [ - "iconCheckedStyles" - ] + "assignedVariables": ["iconCheckedStyles"] } }, "usePrimaryHighContrastStyles": {} @@ -2105,33 +2225,23 @@ "slotName": "root" }, "primaryHighContrastStyles.base": { - "conditions": [ - "appearance === 'primary'" - ], + "conditions": ["appearance === 'primary'"], "slotName": "root" }, "primaryHighContrastStyles.disabled": { - "conditions": [ - "appearance === 'primary' && (disabled || disabledFocusable)" - ], + "conditions": ["appearance === 'primary' && (disabled || disabledFocusable)"], "slotName": "root" }, "rootCheckedStyles.base": { - "conditions": [ - "checked" - ], + "conditions": ["checked"], "slotName": "root" }, "rootCheckedStyles.highContrast": { - "conditions": [ - "checked" - ], + "conditions": ["checked"], "slotName": "root" }, "rootDisabledStyles.base": { - "conditions": [ - "(disabled || disabledFocusable)" - ], + "conditions": ["(disabled || disabledFocusable)"], "slotName": "root" }, "toggleButtonClassNames.icon": { @@ -2147,12 +2257,10 @@ "slotName": "icon" }, "iconCheckedStyles.subtleOrTransparent": { - "conditions": [ - "checked && (appearance === 'subtle' || appearance === 'transparent')" - ], + "conditions": ["checked && (appearance === 'subtle' || appearance === 'transparent')"], "slotName": "icon" } } } } -} \ No newline at end of file +} diff --git a/packages/react-components/token-analyzer-preview/library/package.json b/packages/react-components/token-analyzer-preview/library/package.json index 361386bc335698..0511a051909734 100644 --- a/packages/react-components/token-analyzer-preview/library/package.json +++ b/packages/react-components/token-analyzer-preview/library/package.json @@ -22,7 +22,8 @@ "@fluentui/eslint-plugin": "*", "@fluentui/react-conformance": "*", "@fluentui/react-conformance-griffel": "*", - "@fluentui/scripts-api-extractor": "*" + "@fluentui/scripts-api-extractor": "*", + "@types/prettier": "2.7.2" }, "dependencies": { "@fluentui/react-jsx-runtime": "^9.0.45", @@ -31,7 +32,9 @@ "@fluentui/react-utilities": "^9.18.16", "@griffel/react": "^1.5.22", "@swc/helpers": "^0.5.1", - "ts-morph": "24.0.0" + "ts-morph": "24.0.0", + "typescript": "5.2.2", + "prettier": "2.8.8" }, "peerDependencies": { "@types/react": ">=16.14.0 <19.0.0", diff --git a/packages/react-components/token-analyzer-preview/library/src/__tests__/analyzer.test.ts b/packages/react-components/token-analyzer-preview/library/src/__tests__/analyzer.test.ts index 18c0521aadbd5c..1d63908422a7ae 100644 --- a/packages/react-components/token-analyzer-preview/library/src/__tests__/analyzer.test.ts +++ b/packages/react-components/token-analyzer-preview/library/src/__tests__/analyzer.test.ts @@ -43,6 +43,12 @@ describe('Token Analyzer', () => { token: 'tokens.colorNeutralForeground1', }), ); + expect(styles.useStyles.root.tokens).toContainEqual( + expect.objectContaining({ + property: 'borderRightColor', + token: 'tokens.colorNeutralStrokeDisabled', + }), + ); // Verify anotherSlot styles expect(styles.useStyles.anotherSlot.tokens).toContainEqual( diff --git a/packages/react-components/token-analyzer-preview/library/src/__tests__/cssVarE2E.test.ts b/packages/react-components/token-analyzer-preview/library/src/__tests__/cssVarE2E.test.ts new file mode 100644 index 00000000000000..73826e72648caa --- /dev/null +++ b/packages/react-components/token-analyzer-preview/library/src/__tests__/cssVarE2E.test.ts @@ -0,0 +1,311 @@ +// cssVarE2E.test.ts +import { Project } from 'ts-morph'; +import { analyzeFile } from '../astAnalyzer.js'; +import * as path from 'path'; +import * as fs from 'fs/promises'; + +// Test file contents +const cssVarsStyleFile = ` +import { makeStyles } from '@griffel/react'; +import { tokens } from '@fluentui/react-theme'; +import { colorPrimary, colorSecondary, nestedFallbackVar, complexCssVar } from './tokenVars'; + +const useStyles = makeStyles({ + // Direct token reference + direct: { + color: tokens.colorNeutralForeground1, + }, + // CSS variable with token + cssVar: { + color: \`var(--theme-color, \${tokens.colorBrandForeground4})\`, + }, + // Imported direct token + importedToken: { + color: colorPrimary, + }, + // Imported CSS variable with token + importedCssVar: { + color: colorSecondary, + }, + // Nested CSS variable with token + nestedCssVar: { + background: \`var(--primary, var(--secondary, \${tokens.colorBrandForeground2}))\`, + }, + // Imported nested CSS variable with token + importedNestedVar: { + color: nestedFallbackVar, + }, + // Imported complex CSS variable with multiple tokens + importedComplexVar: { + color: complexCssVar, + }, +}); +`; + +const tokenVarsFile = ` +import { tokens } from '@fluentui/react-theme'; +// Direct token exports +export const colorPrimary = tokens.colorBrandForeground6; +export const colorSecondary = \`var(--color, \${tokens.colorBrandForeground3})\`; + +// Nested fallback vars +export const nestedFallbackVar = \`var(--a, var(--b, \${tokens.colorNeutralForeground3}))\`; + +// Complex vars with multiple tokens +export const complexCssVar = \`var(--complex, var(--nested, \${tokens.colorBrandBackground})) var(--another, \${tokens.colorNeutralBackground1})\`; +`; + +describe('CSS Variable Token Extraction E2E', () => { + let project: Project; + let tempDir: string; + let stylesFilePath: string; + let varsFilePath: string; + + beforeAll(async () => { + // Create temp directory for test files + tempDir = path.join(__dirname, 'temp-e2e-test'); + await fs.mkdir(tempDir, { recursive: true }); + + // Create test files + stylesFilePath = path.join(tempDir, 'test.styles.ts'); + varsFilePath = path.join(tempDir, 'tokenVars.ts'); + + await fs.writeFile(stylesFilePath, cssVarsStyleFile); + await fs.writeFile(varsFilePath, tokenVarsFile); + + // Initialize project + project = new Project({ + tsConfigFilePath: path.join(tempDir, '../../../tsconfig.json'), + skipAddingFilesFromTsConfig: true, + }); + }); + + afterAll(async () => { + // Clean up temp files + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + test('analyzes and extracts all token references from CSS variables', async () => { + // Run the analyzer on our test files + const analysis = await analyzeFile(stylesFilePath, project); + + // Verify the overall structure + expect(analysis).toHaveProperty('styles'); + expect(analysis).toHaveProperty('metadata'); + + const { styles } = analysis; + expect(styles).toHaveProperty('useStyles'); + + const useStyles = styles.useStyles; + + // 1. Verify direct token reference + expect(useStyles.direct.tokens.length).toBe(1); + expect(useStyles.direct.tokens).toContainEqual( + expect.objectContaining({ + property: 'color', + token: 'tokens.colorNeutralForeground1', + }), + ); + + // 2. Verify CSS variable with token + expect(useStyles.cssVar.tokens.length).toBe(1); + expect(useStyles.cssVar.tokens).toContainEqual( + expect.objectContaining({ + property: 'color', + token: 'tokens.colorBrandForeground4', + }), + ); + + // 3. Verify imported direct token + expect(useStyles.importedToken.tokens.length).toBe(1); + expect(useStyles.importedToken.tokens).toContainEqual( + expect.objectContaining({ + property: 'color', + token: 'tokens.colorBrandForeground6', + isVariableReference: true, + }), + ); + + // 4. Verify imported CSS variable with token + expect(useStyles.importedCssVar.tokens.length).toBe(1); + expect(useStyles.importedCssVar.tokens).toContainEqual( + expect.objectContaining({ + property: 'color', + token: 'tokens.colorBrandForeground3', + isVariableReference: true, + }), + ); + + // 5. Verify nested CSS variable with token + expect(useStyles.nestedCssVar.tokens.length).toBe(1); + expect(useStyles.nestedCssVar.tokens).toContainEqual( + expect.objectContaining({ + property: 'background', + token: 'tokens.colorBrandForeground2', + }), + ); + + // 6. Verify imported nested CSS variable with token + expect(useStyles.importedNestedVar.tokens.length).toBe(1); + expect(useStyles.importedNestedVar.tokens).toContainEqual( + expect.objectContaining({ + property: 'color', + token: 'tokens.colorNeutralForeground3', + isVariableReference: true, + }), + ); + + // 8. Verify imported complex CSS variable with multiple tokens + expect(useStyles.importedComplexVar.tokens.length).toBe(2); + expect(useStyles.importedComplexVar.tokens).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + token: 'tokens.colorBrandBackground', + isVariableReference: true, + }), + expect.objectContaining({ + token: 'tokens.colorNeutralBackground1', + isVariableReference: true, + }), + ]), + ); + }); +}); + +// This test focuses on the full end-to-end integration of the CSS variable extraction +// with the module resolution system +describe('CSS Variable Cross-Module Resolution E2E', () => { + let project: Project; + let tempDir: string; + + beforeAll(async () => { + // Create temp directory and subdirectories for test files + tempDir = path.join(__dirname, 'temp-cross-module-test'); + const varsDir = path.join(tempDir, 'tokens'); + const stylesDir = path.join(tempDir, 'styles'); + + await fs.mkdir(varsDir, { recursive: true }); + await fs.mkdir(stylesDir, { recursive: true }); + + // Create a deeper structure to test cross-module resolution + await fs.writeFile( + path.join(varsDir, 'colors.ts'), + ` + import { tokens } from '@fluentui/react-theme'; + // Base token definitions + export const primaryToken = tokens.colorBrandPrimary; + export const secondaryToken = tokens.colorBrandSecondary; + export const furtherMargin = tokens.spacingVerticalXXL; + `, + ); + + await fs.writeFile( + path.join(varsDir, 'variables.ts'), + ` + import { primaryToken, secondaryToken, furtherMargin } from './colors'; + import { tokens } from '@fluentui/react-theme'; + + // CSS Variables referencing tokens + export const primaryVar = \`var(--primary, \${tokens.colorBrandPrimary})\`; + export const nestedVar = \`var(--nested, var(--fallback, \${tokens.colorBrandSecondary}))\`; + export const multiTokenVar = \`var(--multi, \${primaryToken} \${tokens.colorBrandSecondary})\`; + export const someMargin = tokens.spacingHorizontalXXL; + export const someOtherMargin = furtherMargin; + `, + ); + + await fs.writeFile( + path.join(varsDir, 'index.ts'), + ` + // Re-export everything + export * from './colors'; + export * from './variables'; + `, + ); + + await fs.writeFile( + path.join(stylesDir, 'component.styles.ts'), + ` + import { makeStyles } from '@griffel/react'; + import { primaryToken, primaryVar, nestedVar, multiTokenVar, someMargin, someOtherMargin } from '../tokens'; + + const useStyles = makeStyles({ + root: { + // Direct import + color: primaryToken, + // CSS var import + backgroundColor: primaryVar, + // Nested CSS var import + border: nestedVar, + // Complex var with multiple tokens + padding: multiTokenVar, + // aliased and imported CSS var + marginRight:someMargin, + // aliased and imported CSS var with another level of indirection + marginRight:someOtherMargin + } + }); + + export default useStyles; + `, + ); + + // Initialize project + project = new Project({ + tsConfigFilePath: path.join(tempDir, '../../../tsconfig.json'), + skipAddingFilesFromTsConfig: true, + }); + }); + + afterAll(async () => { + // Clean up temp files + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + test('resolves token references across module boundaries with CSS vars', async () => { + // Run the analyzer on the component styles file + const componentPath = path.join(tempDir, 'styles', 'component.styles.ts'); + const analysis = await analyzeFile(componentPath, project); + + const { styles } = analysis; + expect(styles).toHaveProperty('useStyles'); + + const useStyles = styles.useStyles; + const rootStyle = useStyles.root; + + // Verify tokens were extracted from all import types + expect(rootStyle.tokens).toEqual( + expect.arrayContaining([ + // Direct import of token + expect.objectContaining({ + property: 'color', + token: 'tokens.colorBrandPrimary', + isVariableReference: true, + }), + // Import of CSS var with token + expect.objectContaining({ + property: 'backgroundColor', + token: 'tokens.colorBrandPrimary', + isVariableReference: true, + }), + // Import of nested CSS var with token + expect.objectContaining({ + property: 'border', + token: 'tokens.colorBrandSecondary', + isVariableReference: true, + }), + // Multiple tokens from a complex var + expect.objectContaining({ + property: 'padding', + token: 'tokens.colorBrandPrimary', + isVariableReference: true, + }), + expect.objectContaining({ + property: 'padding', + token: 'tokens.colorBrandSecondary', + isVariableReference: true, + }), + ]), + ); + }); +}); diff --git a/packages/react-components/token-analyzer-preview/library/src/__tests__/moduleResolver.test.ts b/packages/react-components/token-analyzer-preview/library/src/__tests__/moduleResolver.test.ts new file mode 100644 index 00000000000000..1fe1364e1b1a78 --- /dev/null +++ b/packages/react-components/token-analyzer-preview/library/src/__tests__/moduleResolver.test.ts @@ -0,0 +1,205 @@ +// moduleResolver.test.ts +import { ModuleResolutionKind, Project, ScriptTarget } from 'ts-morph'; +import { + resolveModulePath, + getModuleSourceFile, + clearModuleCache, + tsUtils, + modulePathCache, + resolvedFilesCache, +} from '../moduleResolver'; +import * as path from 'path'; +import * as fs from 'fs'; + +// Setup test directory and files +const TEST_DIR = path.join(__dirname, 'test-module-resolver'); + +beforeAll(() => { + if (!fs.existsSync(TEST_DIR)) { + fs.mkdirSync(TEST_DIR, { recursive: true }); + } + + // Create test files + fs.writeFileSync( + path.join(TEST_DIR, 'source.ts'), + ` + import { func } from './utils'; + import { theme } from './styles/theme'; + import defaultExport from './constants'; + + const x = func(); + `, + ); + + fs.writeFileSync( + path.join(TEST_DIR, 'utils.ts'), + ` + export const func = () => 'test'; + `, + ); + + fs.mkdirSync(path.join(TEST_DIR, 'styles'), { recursive: true }); + fs.writeFileSync( + path.join(TEST_DIR, 'styles/theme.ts'), + ` + export const theme = { + primary: 'tokens.colors.primary', + secondary: 'tokens.colors.secondary' + }; + `, + ); + + fs.writeFileSync( + path.join(TEST_DIR, 'constants.ts'), + ` + export default 'tokens.default.value'; + `, + ); + + // Create a file with extension in the import + fs.writeFileSync( + path.join(TEST_DIR, 'with-extension.ts'), + ` + import { func } from './utils.ts'; + `, + ); +}); + +afterAll(() => { + if (fs.existsSync(TEST_DIR)) { + fs.rmSync(TEST_DIR, { recursive: true, force: true }); + } +}); + +describe('Module resolver functions', () => { + let project: Project; + + beforeEach(() => { + // Create a fresh project for each test + project = new Project({ + compilerOptions: { + target: ScriptTarget.ES2020, + moduleResolution: ModuleResolutionKind.NodeNext, + }, + }); + + // Clear caches + clearModuleCache(); + }); + + describe('resolveModulePath', () => { + test('resolves relative path correctly', () => { + const sourceFilePath = path.join(TEST_DIR, 'source.ts'); + const result = resolveModulePath(project, './utils', sourceFilePath); + + expect(result).not.toBeNull(); + expect(result).toEqual(path.join(TEST_DIR, 'utils.ts')); + }); + + test('resolves nested relative path correctly', () => { + const sourceFilePath = path.join(TEST_DIR, 'source.ts'); + const result = resolveModulePath(project, './styles/theme', sourceFilePath); + + expect(result).not.toBeNull(); + expect(result).toEqual(path.join(TEST_DIR, 'styles/theme.ts')); + }); + + test('resolves path with file extension', () => { + const sourceFilePath = path.join(TEST_DIR, 'with-extension.ts'); + const result = resolveModulePath(project, './utils.ts', sourceFilePath); + + expect(result).not.toBeNull(); + expect(result).toEqual(path.join(TEST_DIR, 'utils.ts')); + }); + + test('returns null for non-existent module', () => { + const sourceFilePath = path.join(TEST_DIR, 'source.ts'); + const result = resolveModulePath(project, './non-existent', sourceFilePath); + + expect(result).toBeNull(); + }); + + test('caches resolution results', () => { + const sourceFilePath = path.join(TEST_DIR, 'source.ts'); + + // First call should resolve + const firstResult = resolveModulePath(project, './utils', sourceFilePath); + expect(firstResult).not.toBeNull(); + + // Mock the TS resolution to verify cache is used + const originalResolve = tsUtils.resolveModuleName; + tsUtils.resolveModuleName = jest.fn().mockImplementation(() => { + throw new Error('Should not be called if cache is working'); + }); + + // Second call should use cache + const secondResult = resolveModulePath(project, './utils', sourceFilePath); + expect(secondResult).toEqual(firstResult); + + // Restore original function + tsUtils.resolveModuleName = originalResolve; + }); + }); + + describe('getModuleSourceFile', () => { + test('returns source file for valid module', () => { + const sourceFilePath = path.join(TEST_DIR, 'source.ts'); + project.addSourceFileAtPath(sourceFilePath); + + const result = getModuleSourceFile(project, './utils', sourceFilePath); + + expect(result).not.toBeNull(); + expect(result?.getFilePath()).toEqual(path.join(TEST_DIR, 'utils.ts')); + }); + + test('caches source files', () => { + const sourceFilePath = path.join(TEST_DIR, 'source.ts'); + project.addSourceFileAtPath(sourceFilePath); + + // First call + const firstResult = getModuleSourceFile(project, './utils', sourceFilePath); + expect(firstResult).not.toBeNull(); + + // Mock project.addSourceFileAtPath to verify cache is used + const originalAddSourceFile = project.addSourceFileAtPath; + project.addSourceFileAtPath = jest.fn().mockImplementation(() => { + throw new Error('Should not be called if cache is working'); + }); + + // Second call should use cache + const secondResult = getModuleSourceFile(project, './utils', sourceFilePath); + expect(secondResult).toBe(firstResult); // Same instance + + // Restore original function + project.addSourceFileAtPath = originalAddSourceFile; + }); + + test('returns null for non-existent module', () => { + const sourceFilePath = path.join(TEST_DIR, 'source.ts'); + project.addSourceFileAtPath(sourceFilePath); + + const result = getModuleSourceFile(project, './non-existent', sourceFilePath); + expect(result).toBeNull(); + }); + }); + + test('clearModuleCache clears both caches', () => { + const sourceFilePath = path.join(TEST_DIR, 'source.ts'); + project.addSourceFileAtPath(sourceFilePath); + + // Fill the caches + getModuleSourceFile(project, './utils', sourceFilePath); + getModuleSourceFile(project, './styles/theme', sourceFilePath); + + // Verify caches were filled + expect(modulePathCache.size).toBeGreaterThan(0); + expect(resolvedFilesCache.size).toBeGreaterThan(0); + + // Clear caches + clearModuleCache(); + + // Directly verify caches are empty + expect(modulePathCache.size).toBe(0); + expect(resolvedFilesCache.size).toBe(0); + }); +}); diff --git a/packages/react-components/token-analyzer-preview/library/src/__tests__/packageImports.test.ts b/packages/react-components/token-analyzer-preview/library/src/__tests__/packageImports.test.ts new file mode 100644 index 00000000000000..5977f7b6b0a975 --- /dev/null +++ b/packages/react-components/token-analyzer-preview/library/src/__tests__/packageImports.test.ts @@ -0,0 +1,187 @@ +// packageImports.test.ts +import { Project, ModuleResolutionKind, ScriptTarget } from 'ts-morph'; +import { resolveModulePath, clearModuleCache, tsUtils } from '../moduleResolver'; +import * as path from 'path'; +import * as fs from 'fs'; + +// Setup test directory and mock node_modules structure +const TEST_DIR = path.join(__dirname, 'test-package-imports'); +const NODE_MODULES = path.join(TEST_DIR, 'node_modules'); +const SCOPED_PACKAGE = path.join(NODE_MODULES, '@scope', 'package'); +const REGULAR_PACKAGE = path.join(NODE_MODULES, 'some-package'); + +beforeAll(() => { + // Create test directories + if (!fs.existsSync(TEST_DIR)) { + fs.mkdirSync(TEST_DIR, { recursive: true }); + } + if (!fs.existsSync(SCOPED_PACKAGE)) { + fs.mkdirSync(SCOPED_PACKAGE, { recursive: true }); + } + if (!fs.existsSync(REGULAR_PACKAGE)) { + fs.mkdirSync(REGULAR_PACKAGE, { recursive: true }); + } + + // Create a source file that imports from packages + fs.writeFileSync( + path.join(TEST_DIR, 'source.ts'), + ` + import { Component } from '@scope/package'; + import { helper } from 'some-package'; + `, + ); + + // Create package.json and index files for the scoped package + fs.writeFileSync( + path.join(SCOPED_PACKAGE, 'package.json'), + JSON.stringify({ + name: '@scope/package', + version: '1.0.0', + main: 'index.js', + }), + ); + fs.writeFileSync( + path.join(SCOPED_PACKAGE, 'index.js'), + ` + export const Component = { + theme: 'tokens.components.primary' + }; + `, + ); + + // Create package.json and index files for the regular package + fs.writeFileSync( + path.join(REGULAR_PACKAGE, 'package.json'), + JSON.stringify({ + name: 'some-package', + version: '1.0.0', + main: './lib/index.js', + }), + ); + + // Create lib directory in the regular package + fs.mkdirSync(path.join(REGULAR_PACKAGE, 'lib'), { recursive: true }); + + fs.writeFileSync( + path.join(REGULAR_PACKAGE, 'lib', 'index.js'), + ` + export const helper = 'tokens.helpers.main'; + `, + ); +}); + +afterAll(() => { + if (fs.existsSync(TEST_DIR)) { + fs.rmSync(TEST_DIR, { recursive: true, force: true }); + } +}); + +describe('Package imports resolution', () => { + let project: Project; + let originalResolve: any; + let originalFileExists: any; + + beforeEach(() => { + project = new Project({ + compilerOptions: { + target: ScriptTarget.ES2020, + moduleResolution: ModuleResolutionKind.NodeNext, + }, + }); + + // Setup workspace + project.addSourceFileAtPath(path.join(TEST_DIR, 'source.ts')); + + // Clear caches + clearModuleCache(); + + // Store original functions + originalResolve = tsUtils.resolveModuleName; + originalFileExists = tsUtils.fileExists; + + // Mock fileExists to handle our mock node_modules + tsUtils.fileExists = jest.fn().mockImplementation((filePath: string) => { + return fs.existsSync(filePath); + }); + }); + + afterEach(() => { + // Restore original functions + tsUtils.resolveModuleName = originalResolve; + tsUtils.fileExists = originalFileExists; + }); + + test('resolves scoped package imports correctly', () => { + const sourceFilePath = path.join(TEST_DIR, 'source.ts'); + + // Mock the TypeScript resolution for scoped packages + tsUtils.resolveModuleName = jest + .fn() + .mockImplementation((moduleName: string, containingFile: string, compilerOptions: any, host: any) => { + if (moduleName === '@scope/package') { + return { + resolvedModule: { + resolvedFileName: path.join(SCOPED_PACKAGE, 'index.js'), + extension: '.js', + isExternalLibraryImport: true, + }, + }; + } + // Call original for other cases + return originalResolve(moduleName, containingFile, compilerOptions, host); + }); + + const result = resolveModulePath(project, '@scope/package', sourceFilePath); + + expect(result).not.toBeNull(); + expect(result).toEqual(path.join(SCOPED_PACKAGE, 'index.js')); + expect(tsUtils.resolveModuleName).toHaveBeenCalled(); + }); + + test('resolves regular package imports with non-standard main path', () => { + const sourceFilePath = path.join(TEST_DIR, 'source.ts'); + + // Mock the TypeScript resolution for regular packages + tsUtils.resolveModuleName = jest + .fn() + .mockImplementation((moduleName: string, containingFile: string, compilerOptions: any, host: any) => { + if (moduleName === 'some-package') { + return { + resolvedModule: { + resolvedFileName: path.join(REGULAR_PACKAGE, 'lib', 'index.js'), + extension: '.js', + isExternalLibraryImport: true, + }, + }; + } + // Call original for other cases + return originalResolve(moduleName, containingFile, compilerOptions, host); + }); + + const result = resolveModulePath(project, 'some-package', sourceFilePath); + + expect(result).not.toBeNull(); + expect(result).toEqual(path.join(REGULAR_PACKAGE, 'lib', 'index.js')); + expect(tsUtils.resolveModuleName).toHaveBeenCalled(); + }); + + test('returns null for non-existent packages', () => { + const sourceFilePath = path.join(TEST_DIR, 'source.ts'); + + // Mock the TypeScript resolution to return null for non-existent packages + tsUtils.resolveModuleName = jest + .fn() + .mockImplementation((moduleName: string, containingFile: string, compilerOptions: any, host: any) => { + if (moduleName === 'non-existent-package') { + return { resolvedModule: undefined }; + } + // Call original for other cases + return originalResolve(moduleName, containingFile, compilerOptions, host); + }); + + const result = resolveModulePath(project, 'non-existent-package', sourceFilePath); + + expect(result).toBeNull(); + expect(tsUtils.resolveModuleName).toHaveBeenCalled(); + }); +}); diff --git a/packages/react-components/token-analyzer-preview/library/src/__tests__/reexportTracking.test.ts b/packages/react-components/token-analyzer-preview/library/src/__tests__/reexportTracking.test.ts new file mode 100644 index 00000000000000..338deae748b7bb --- /dev/null +++ b/packages/react-components/token-analyzer-preview/library/src/__tests__/reexportTracking.test.ts @@ -0,0 +1,177 @@ +// reexportTracking.test.ts +import { Project } from 'ts-morph'; +import { analyzeImports, ImportedValue } from '../importAnalyzer'; +import * as path from 'path'; +import * as fs from 'fs'; + +// Setup test directory with a chain of re-exports +const TEST_DIR = path.join(__dirname, 'test-reexports'); + +beforeAll(() => { + if (!fs.existsSync(TEST_DIR)) { + fs.mkdirSync(TEST_DIR, { recursive: true }); + } + + // Create a main file that imports from an index + fs.writeFileSync( + path.join(TEST_DIR, 'main.ts'), + ` + import { Component, AliasedValue, Utils, DirectValue } from './index'; + import DefaultExport from './index'; + + const styles = { + component: Component, + alias: AliasedValue, + utils: Utils, + direct: DirectValue, + default: DefaultExport + }; + `, + ); + + // Create an index file that re-exports everything + fs.writeFileSync( + path.join(TEST_DIR, 'index.ts'), + ` + // Re-export from components + export { Component } from './components'; + + // Re-export with alias + export { Value as AliasedValue } from './values'; + + // Re-export all from utils + export * from './utils'; + + // Direct export + export const DirectValue = 'tokens.direct.value'; + + // Re-export default + export { default } from './defaults'; + `, + ); + + // Create a components file + fs.writeFileSync( + path.join(TEST_DIR, 'components.ts'), + ` + export const Component = 'tokens.components.primary'; + `, + ); + + // Create a values file + fs.writeFileSync( + path.join(TEST_DIR, 'values.ts'), + ` + export const Value = 'tokens.values.standard'; + `, + ); + + // Create a utils file + fs.writeFileSync( + path.join(TEST_DIR, 'utils.ts'), + ` + export const Utils = 'tokens.utils.helper'; + `, + ); + + // Create a defaults file + fs.writeFileSync( + path.join(TEST_DIR, 'defaults.ts'), + ` + const DefaultValue = 'tokens.defaults.main'; + export default DefaultValue; + `, + ); +}); + +afterAll(() => { + if (fs.existsSync(TEST_DIR)) { + fs.rmSync(TEST_DIR, { recursive: true, force: true }); + } +}); + +describe('Re-export tracking', () => { + let project: Project; + + beforeEach(() => { + // Create a project using the existing directory structure + // This makes it easier to test without needing to override compiler options + project = new Project({ + tsConfigFilePath: path.join(TEST_DIR, '../../../tsconfig.json'), + skipAddingFilesFromTsConfig: true, + }); + + // Create a minimal tsconfig.json + fs.writeFileSync( + path.join(TEST_DIR, 'tsconfig.json'), + JSON.stringify({ + compilerOptions: { + target: 'es2020', + moduleResolution: 'node', + esModuleInterop: true, + skipLibCheck: true, + }, + }), + ); + }); + + test('follows standard re-export chain', async () => { + const mainFile = path.join(TEST_DIR, 'main.ts'); + const sourceFile = project.addSourceFileAtPath(mainFile); + + const importedValues: Map = await analyzeImports(sourceFile, project); + + // Check that Component was correctly resolved from components.ts + expect(importedValues.has('Component')).toBe(true); + expect(importedValues.get('Component')?.value).toBe('tokens.components.primary'); + expect(importedValues.get('Component')?.sourceFile).toContain('components.ts'); + }); + + test('follows aliased re-export chain', async () => { + const mainFile = path.join(TEST_DIR, 'main.ts'); + const sourceFile = project.addSourceFileAtPath(mainFile); + + const importedValues: Map = await analyzeImports(sourceFile, project); + + // Check that AliasedValue was correctly resolved from values.ts + expect(importedValues.has('AliasedValue')).toBe(true); + expect(importedValues.get('AliasedValue')?.value).toBe('tokens.values.standard'); + expect(importedValues.get('AliasedValue')?.sourceFile).toContain('values.ts'); + }); + + test('follows namespace re-export', async () => { + const mainFile = path.join(TEST_DIR, 'main.ts'); + const sourceFile = project.addSourceFileAtPath(mainFile); + + const importedValues: Map = await analyzeImports(sourceFile, project); + + // Check that Utils from namespace export was correctly resolved + expect(importedValues.has('Utils')).toBe(true); + expect(importedValues.get('Utils')?.value).toBe('tokens.utils.helper'); + expect(importedValues.get('Utils')?.sourceFile).toContain('utils.ts'); + }); + + test('handles direct exports in the same file', async () => { + const mainFile = path.join(TEST_DIR, 'main.ts'); + const sourceFile = project.addSourceFileAtPath(mainFile); + + const importedValues: Map = await analyzeImports(sourceFile, project); + + // Check that DirectValue was correctly resolved from index.ts + expect(importedValues.has('DirectValue')).toBe(true); + expect(importedValues.get('DirectValue')?.value).toBe('tokens.direct.value'); + expect(importedValues.get('DirectValue')?.sourceFile).toContain('index.ts'); + }); + + test('follows default export chain', async () => { + const mainFile = path.join(TEST_DIR, 'main.ts'); + const sourceFile = project.addSourceFileAtPath(mainFile); + + const importedValues: Map = await analyzeImports(sourceFile, project); + + // Check that DefaultExport was correctly resolved from defaults.ts + expect(importedValues.has('DefaultExport')).toBe(true); + expect(importedValues.get('DefaultExport')?.value).toBe('tokens.defaults.main'); + expect(importedValues.get('DefaultExport')?.sourceFile).toContain('defaults.ts'); + }); +}); diff --git a/packages/react-components/token-analyzer-preview/library/src/__tests__/sample-styles.ts b/packages/react-components/token-analyzer-preview/library/src/__tests__/sample-styles.ts index cac52146c4d6ad..8deebab2fb35d1 100644 --- a/packages/react-components/token-analyzer-preview/library/src/__tests__/sample-styles.ts +++ b/packages/react-components/token-analyzer-preview/library/src/__tests__/sample-styles.ts @@ -10,6 +10,7 @@ const useStyles = makeStyles({ root: { color: tokens.colorNeutralForeground1, backgroundColor: tokens.colorNeutralBackground1, + ...shorthands.borderColor(tokens.colorNeutralStrokeDisabled), ':hover': { color: tokens.colorNeutralForegroundHover, } diff --git a/packages/react-components/token-analyzer-preview/library/src/__tests__/typeCheckerImports.test.ts b/packages/react-components/token-analyzer-preview/library/src/__tests__/typeCheckerImports.test.ts new file mode 100644 index 00000000000000..890ae2621b1b95 --- /dev/null +++ b/packages/react-components/token-analyzer-preview/library/src/__tests__/typeCheckerImports.test.ts @@ -0,0 +1,152 @@ +// typeCheckerImports.test.ts +import { Project } from 'ts-morph'; +import { analyzeImports, ImportedValue } from '../importAnalyzer'; +import * as path from 'path'; +import * as fs from 'fs'; + +// Setup test directory with a chain of re-exports +const TEST_DIR = path.join(__dirname, 'test-type-checker'); + +beforeAll(() => { + if (!fs.existsSync(TEST_DIR)) { + fs.mkdirSync(TEST_DIR, { recursive: true }); + } + + // Create a main file that imports from an index + fs.writeFileSync( + path.join(TEST_DIR, 'main.ts'), + ` + import { Component, AliasedValue, Utils, DirectValue } from './index'; + import DefaultExport from './index'; + + const styles = { + component: Component, + alias: AliasedValue, + utils: Utils, + direct: DirectValue, + default: DefaultExport + }; + `, + ); + + // Create an index file that re-exports everything + fs.writeFileSync( + path.join(TEST_DIR, 'index.ts'), + ` + // Re-export from components + export { Component } from './components'; + + // Re-export with alias + export { Value as AliasedValue } from './values'; + + // Re-export all from utils + export * from './utils'; + + // Direct export + export const DirectValue = 'tokens.direct.value'; + + // Re-export default + export { default } from './defaults'; + `, + ); + + // Create a components file + fs.writeFileSync( + path.join(TEST_DIR, 'components.ts'), + ` + export const Component = 'tokens.components.primary'; + `, + ); + + // Create a values file + fs.writeFileSync( + path.join(TEST_DIR, 'values.ts'), + ` + export const Value = 'tokens.values.standard'; + `, + ); + + // Create a utils file + fs.writeFileSync( + path.join(TEST_DIR, 'utils.ts'), + ` + export const Utils = 'tokens.utils.helper'; + `, + ); + + // Create a defaults file + fs.writeFileSync( + path.join(TEST_DIR, 'defaults.ts'), + ` + const DefaultValue = 'tokens.defaults.main'; + export default DefaultValue; + `, + ); +}); + +afterAll(() => { + if (fs.existsSync(TEST_DIR)) { + fs.rmSync(TEST_DIR, { recursive: true, force: true }); + } +}); + +describe('Type Checker Import Analysis', () => { + let project: Project; + + beforeEach(() => { + // Create a project using the existing directory structure + // This makes it easier to test without needing to override compiler options + project = new Project({ + tsConfigFilePath: path.join(TEST_DIR, '../../../tsconfig.json'), + skipAddingFilesFromTsConfig: true, + }); + + // Create a minimal tsconfig.json + fs.writeFileSync( + path.join(TEST_DIR, 'tsconfig.json'), + JSON.stringify({ + compilerOptions: { + target: 'es2020', + moduleResolution: 'node', + esModuleInterop: true, + skipLibCheck: true, + }, + }), + ); + }); + + test('follows all re-export types using type checker', async () => { + const mainFile = path.join(TEST_DIR, 'main.ts'); + const sourceFile = project.addSourceFileAtPath(mainFile); + + // Add all other files to ensure project has complete type information + project.addSourceFilesAtPaths([path.join(TEST_DIR, '**/*.ts')]); + + const importedValues: Map = await analyzeImports(sourceFile, project); + + // Verify standard re-export (Component) + expect(importedValues.has('Component')).toBe(true); + expect(importedValues.get('Component')?.value).toBe('tokens.components.primary'); + expect(importedValues.get('Component')?.sourceFile).toContain('components.ts'); + + // Verify aliased re-export (AliasedValue) + expect(importedValues.has('AliasedValue')).toBe(true); + expect(importedValues.get('AliasedValue')?.value).toBe('tokens.values.standard'); + expect(importedValues.get('AliasedValue')?.sourceFile).toContain('values.ts'); + + // Verify namespace re-export (Utils) + expect(importedValues.has('Utils')).toBe(true); + expect(importedValues.get('Utils')?.value).toBe('tokens.utils.helper'); + expect(importedValues.get('Utils')?.sourceFile).toContain('utils.ts'); + + // Verify direct export (DirectValue) + expect(importedValues.has('DirectValue')).toBe(true); + expect(importedValues.get('DirectValue')?.value).toBe('tokens.direct.value'); + expect(importedValues.get('DirectValue')?.sourceFile).toContain('index.ts'); + + // Verify default export (DefaultExport) + expect(importedValues.has('DefaultExport')).toBe(true); + expect(importedValues.get('DefaultExport')?.value).toBe('tokens.defaults.main'); + expect(importedValues.get('DefaultExport')?.sourceFile).toContain('defaults.ts'); + }); +}); diff --git a/packages/react-components/token-analyzer-preview/library/src/__tests__/verifyFileExists.test.ts b/packages/react-components/token-analyzer-preview/library/src/__tests__/verifyFileExists.test.ts new file mode 100644 index 00000000000000..a9f5937daa0f90 --- /dev/null +++ b/packages/react-components/token-analyzer-preview/library/src/__tests__/verifyFileExists.test.ts @@ -0,0 +1,104 @@ +// verifyFileExists.test.ts +import * as path from 'path'; +import * as fs from 'fs'; +import { tsUtils, verifyFileExists } from '../moduleResolver'; + +// Setup test directory and files +const TEST_DIR = path.join(__dirname, 'test-verify-files'); +const EXISTING_FILE = path.join(TEST_DIR, 'exists.txt'); +const NON_EXISTENT_FILE = path.join(TEST_DIR, 'does-not-exist.txt'); + +beforeAll(() => { + if (!fs.existsSync(TEST_DIR)) { + fs.mkdirSync(TEST_DIR, { recursive: true }); + } + + // Create a file we know exists + fs.writeFileSync(EXISTING_FILE, 'This file exists'); + + // Make sure our non-existent file really doesn't exist + if (fs.existsSync(NON_EXISTENT_FILE)) { + fs.unlinkSync(NON_EXISTENT_FILE); + } +}); + +afterAll(() => { + if (fs.existsSync(TEST_DIR)) { + fs.rmSync(TEST_DIR, { recursive: true, force: true }); + } +}); + +describe('verifyFileExists', () => { + // Store original functions to restore after tests + const originalFileExists = tsUtils.fileExists; + + afterEach(() => { + // Restore original functions after each test + tsUtils.fileExists = originalFileExists; + }); + + test('returns true for existing files', () => { + expect(verifyFileExists(EXISTING_FILE)).toBe(true); + }); + + test('returns false for non-existent files', () => { + expect(verifyFileExists(NON_EXISTENT_FILE)).toBe(false); + }); + + test('returns false for null or undefined paths', () => { + expect(verifyFileExists(null)).toBe(false); + expect(verifyFileExists(undefined)).toBe(false); + }); + + test('uses tsUtils.fileExists when available', () => { + // Mock the tsUtils.fileExists function + tsUtils.fileExists = jest.fn().mockImplementation(filePath => { + return filePath === EXISTING_FILE; + }); + + expect(verifyFileExists(EXISTING_FILE)).toBe(true); + expect(verifyFileExists(NON_EXISTENT_FILE)).toBe(false); + expect(tsUtils.fileExists).toHaveBeenCalledTimes(2); + }); + + test('falls back to fs.existsSync when tsUtils.fileExists throws', () => { + // Mock tsUtils.fileExists to throw an error + tsUtils.fileExists = jest.fn().mockImplementation(() => { + throw new Error('fileExists not available'); + }); + + // Spy on fs.existsSync + const existsSyncSpy = jest.spyOn(fs, 'existsSync'); + + // Test should still work using fs.existsSync + expect(verifyFileExists(EXISTING_FILE)).toBe(true); + expect(verifyFileExists(NON_EXISTENT_FILE)).toBe(false); + + // Verify tsUtils.fileExists was called and threw an error + expect(tsUtils.fileExists).toHaveBeenCalledTimes(2); + + // Verify fs.existsSync was used as fallback + expect(existsSyncSpy).toHaveBeenCalledTimes(2); + + // Restore the original spy + existsSyncSpy.mockRestore(); + }); + + test('returns false when both fileExists mechanisms fail', () => { + // Mock tsUtils.fileExists to throw + tsUtils.fileExists = jest.fn().mockImplementation(() => { + throw new Error('fileExists not available'); + }); + + // Mock fs.existsSync to throw + const existsSyncSpy = jest.spyOn(fs, 'existsSync').mockImplementation(() => { + throw new Error('existsSync not available'); + }); + + // Should safely return false when everything fails + expect(verifyFileExists(EXISTING_FILE)).toBe(false); + + // Restore the original spy + existsSyncSpy.mockRestore(); + }); +}); diff --git a/packages/react-components/token-analyzer-preview/library/src/astAnalyzer.ts b/packages/react-components/token-analyzer-preview/library/src/astAnalyzer.ts index 06f382a10f8144..3b02ce229215e6 100644 --- a/packages/react-components/token-analyzer-preview/library/src/astAnalyzer.ts +++ b/packages/react-components/token-analyzer-preview/library/src/astAnalyzer.ts @@ -1,5 +1,5 @@ /* eslint-disable no-console */ -import { Project, Node, SourceFile, PropertyAssignment } from 'ts-morph'; +import { Project, Node, SourceFile, PropertyAssignment, SpreadAssignment } from 'ts-morph'; import { TokenReference, StyleAnalysis, @@ -11,6 +11,9 @@ import { StyleTokens, } from './types.js'; import { log, measure, measureAsync } from './debugUtils.js'; +import { analyzeImports, processImportedStringTokens, ImportedValue } from './importAnalyzer.js'; +import { extractTokensFromCssVars } from './cssVarTokenExtractor.js'; +import { extractTokensFromText, getPropertiesForShorthand, isTokenReference } from './tokenUtils.js'; const makeResetStylesToken = 'resetStyles'; @@ -29,10 +32,18 @@ interface VariableMapping { * Process a style property to extract token references. * Property names are derived from the actual CSS property in the path, * not the object key containing them. + * + * @param prop The property assignment or spread element to process + * @param importedValues Map of imported values for resolving token references + * @param isResetStyles Whether this is a reset styles property */ -function processStyleProperty(prop: PropertyAssignment, isResetStyles?: Boolean): TokenReference[] { +function processStyleProperty( + prop: PropertyAssignment | SpreadAssignment, + importedValues: Map | undefined = undefined, + isResetStyles?: Boolean, +): TokenReference[] { const tokens: TokenReference[] = []; - const parentName = prop.getName(); + const parentName = Node.isPropertyAssignment(prop) ? prop.getName() : ''; function processNode(node?: Node, path: string[] = []): void { if (!node) { @@ -40,14 +51,37 @@ function processStyleProperty(prop: PropertyAssignment, isResetStyles?: Boolean) } // If we're processing a reset style, we need to add the parent name to the path - if (isResetStyles && path.length === 0) { + if (isResetStyles && path.length === 0 && parentName) { path.push(parentName); } - if (Node.isStringLiteral(node) || Node.isIdentifier(node)) { + // Check for string literals or template expressions (string template literals) + if (Node.isStringLiteral(node) || Node.isTemplateExpression(node)) { + const text = node.getText().replace(/['"]/g, ''); // Remove quotes + + // Check for CSS var() syntax that might contain tokens + if (text.includes('var(')) { + const cssVarTokens = extractTokensFromCssVars(text, path[path.length - 1] || parentName, path, TOKEN_REGEX); + tokens.push(...cssVarTokens); + } else { + // Check for direct token references + const matches = extractTokensFromText(node); + if (matches.length > 0) { + matches.forEach(match => { + tokens.push({ + property: path[path.length - 1] || parentName, + token: match, + path, + }); + }); + } + } + } else if (Node.isIdentifier(node)) { const text = node.getText(); - const matches = text.match(TOKEN_REGEX); - if (matches) { + + // First check if it matches the token regex directly + const matches = extractTokensFromText(node); + if (matches.length > 0) { matches.forEach(match => { tokens.push({ property: path[path.length - 1] || parentName, @@ -56,9 +90,22 @@ function processStyleProperty(prop: PropertyAssignment, isResetStyles?: Boolean) }); }); } + + // Then check if it's an imported value reference + if (importedValues && importedValues.has(text)) { + const importTokens = processImportedStringTokens( + importedValues, + path[path.length - 1] || parentName, + text, + path, + TOKEN_REGEX, + ); + tokens.push(...importTokens); + } } else if (Node.isPropertyAccessExpression(node)) { const text = node.getText(); - if (text.startsWith('tokens.')) { + const isToken = isTokenReference(text); + if (isToken) { tokens.push({ property: path[path.length - 1] || parentName, token: text, @@ -70,8 +117,14 @@ function processStyleProperty(prop: PropertyAssignment, isResetStyles?: Boolean) if (Node.isPropertyAssignment(childProp)) { const childName = childProp.getName(); processNode(childProp.getInitializer(), [...path, childName]); + } else if (Node.isSpreadAssignment(childProp)) { + // Handle spread elements in object literals + processNode(childProp.getExpression(), path); } }); + } else if (Node.isSpreadAssignment(node)) { + // Handle spread elements + processNode(node.getExpression(), path); } else if (Node.isCallExpression(node) && node.getExpression().getText() === 'createCustomFocusIndicatorStyle') { const focus = `:focus`; const focusWithin = `:focus-within`; @@ -100,34 +153,128 @@ function processStyleProperty(prop: PropertyAssignment, isResetStyles?: Boolean) passedTokens.getProperties().forEach(property => { if (Node.isPropertyAssignment(property)) { const childName = property.getName(); - console.log('Get child name:', childName); processNode(property.getInitializer(), [...path, nestedModifier, childName]); } }); } } else if (Node.isCallExpression(node)) { - // Generic handling of functions that are not whitelisted - stored passed tokens under function name + // Process calls like shorthands.borderColor(tokens.color) const functionName = node.getExpression().getText(); - node.getArguments().forEach(argument => { - if (Node.isObjectLiteralExpression(argument)) { - argument.getProperties().forEach(property => { - if (Node.isPropertyAssignment(property)) { - const childName = property.getName(); - processNode(property.getInitializer(), [...path, functionName, childName]); + // we should pass the number of arguments so we can properly map which overload is being called. + const affectedProperties = getPropertiesForShorthand(functionName); + + if (affectedProperties.length > 0) { + // Process each argument and apply it to all affected properties + node.getArguments().forEach(argument => { + processNodeForAffectedProperties(argument, affectedProperties, path); + }); + } else { + // Generic handling of functions that are not whitelisted + node.getArguments().forEach(argument => { + if (Node.isObjectLiteralExpression(argument)) { + argument.getProperties().forEach(property => { + if (Node.isPropertyAssignment(property)) { + const childName = property.getName(); + processNode(property.getInitializer(), [...path, functionName, childName]); + } + }); + } + // Check for string literals in function arguments that might contain CSS variables with tokens + if (Node.isStringLiteral(argument)) { + const text = argument.getText().replace(/['"]/g, ''); + if (text.includes('var(')) { + const cssVarTokens = extractTokensFromCssVars( + text, + path[path.length - 1] || parentName, + [...path, functionName], + TOKEN_REGEX, + ); + tokens.push(...cssVarTokens); } - }); - } + } + }); + } + } + } + + // Helper function to process nodes for multiple affected properties + function processNodeForAffectedProperties(node: Node, properties: string[], basePath: string[]): void { + if (!node) { + return; + } + + // If this is a direct token reference + if (Node.isPropertyAccessExpression(node) && isTokenReference(node)) { + properties.forEach(property => { + tokens.push({ + property, + token: node.getText(), + path: basePath.concat(property), + }); }); + return; + } + + // If this is an identifier that might be a variable + if (Node.isIdentifier(node) && importedValues && importedValues.has(node.getText())) { + properties.forEach(property => { + const importTokens = processImportedStringTokens( + importedValues, + property, + node.getText(), + basePath, + TOKEN_REGEX, + ); + tokens.push(...importTokens); + }); + return; + } + + // For other node types, process them normally but with each property + if (Node.isStringLiteral(node) || Node.isTemplateExpression(node)) { + const text = node.getText().replace(/['"]/g, ''); + + // Check for tokens in the text + const matches = extractTokensFromText(node); + if (matches.length > 0) { + properties.forEach(property => { + matches.forEach(match => { + tokens.push({ + property, + token: match, + path: basePath, + }); + }); + }); + } + + // Check for CSS vars + if (text.includes('var(')) { + properties.forEach(property => { + const cssVarTokens = extractTokensFromCssVars(text, property, basePath, TOKEN_REGEX); + tokens.push(...cssVarTokens); + }); + } + } + + // For any other complex expressions, process them normally + else { + processNode(node, basePath); } } - const initializer = prop.getInitializer(); - if (initializer) { - processNode(initializer); + if (Node.isPropertyAssignment(prop)) { + const initializer = prop.getInitializer(); + if (initializer) { + processNode(initializer); + } + } else if (Node.isSpreadAssignment(prop)) { + processNode(prop.getExpression()); } return tokens; } + /** * Analyzes mergeClasses calls to determine style relationships */ @@ -201,7 +348,9 @@ function analyzeMergeClasses(sourceFile: SourceFile): StyleMapping[] { */ function createStyleContent(tokens: TokenReference[]): StyleContent { const content: StyleContent = { - tokens: tokens.filter(t => t.path.length === 1), + tokens: tokens.filter(t => { + return t.path.length === 1; + }), }; // Nested structures have paths longer than 1 @@ -264,7 +413,10 @@ function createMetadata(styleMappings: StyleMapping[]): StyleMetadata { /** * Analyzes makeStyles calls to get token usage and structure */ -async function analyzeMakeStyles(sourceFile: SourceFile): Promise { +async function analyzeMakeStyles( + sourceFile: SourceFile, + importedValues: Map | undefined = undefined, +): Promise { const analysis: StyleAnalysis = {}; sourceFile.forEachDescendant(node => { @@ -276,7 +428,7 @@ async function analyzeMakeStyles(sourceFile: SourceFile): Promise stylesArg.getProperties().forEach(prop => { if (Node.isPropertyAssignment(prop)) { const styleName = prop.getName(); - const tokens = processStyleProperty(prop); + const tokens = processStyleProperty(prop, importedValues); const functionName = parentNode.getName(); if (!analysis[functionName]) { analysis[functionName] = {}; @@ -306,7 +458,7 @@ async function analyzeMakeStyles(sourceFile: SourceFile): Promise // Process the styles object stylesArg.getProperties().forEach(prop => { if (Node.isPropertyAssignment(prop)) { - const tokens = processStyleProperty(prop, true); + const tokens = processStyleProperty(prop, importedValues, true); if (tokens.length) { const styleContent = createStyleContent(tokens); analysis[functionName][makeResetStylesToken].tokens = analysis[functionName][ @@ -359,18 +511,24 @@ async function analyzeMakeStyles(sourceFile: SourceFile): Promise } /** - * Combines mergeClasses and makeStyles analysis + * Combines mergeClasses and makeStyles analysis, with import resolution */ async function analyzeFile(filePath: string, project: Project): Promise { log(`Analyzing ${filePath}`); const sourceFile = project.addSourceFileAtPath(filePath); - // First pass: Analyze mergeClasses + // First analyze imports to find imported string values + log('Analyzing imports to find imported token values'); + const importedValues = await measureAsync('analyze imports', () => analyzeImports(sourceFile, project)); + + // Second pass: Analyze mergeClasses const styleMappings = measure('analyze mergeClasses', () => analyzeMergeClasses(sourceFile)); - // Second pass: Analyze makeStyles - const styleAnalysis = await measureAsync('analyze makeStyles', () => analyzeMakeStyles(sourceFile)); + // Third pass: Analyze makeStyles with imported values + const styleAnalysis = await measureAsync('analyze makeStyles', () => + analyzeMakeStyles(sourceFile, importedValues), + ); // Create enhanced analysis with separated styles and metadata return { diff --git a/packages/react-components/token-analyzer-preview/library/src/cssVarTokenExtractor.ts b/packages/react-components/token-analyzer-preview/library/src/cssVarTokenExtractor.ts new file mode 100644 index 00000000000000..ed8753e9201653 --- /dev/null +++ b/packages/react-components/token-analyzer-preview/library/src/cssVarTokenExtractor.ts @@ -0,0 +1,90 @@ +// cssVarTokenExtractor.ts +import { log } from './debugUtils.js'; +import { TokenReference } from './types.js'; +import { extractTokensFromText } from './tokenUtils.js'; + +/** + * Extracts token references from CSS variable syntax including nested fallback chains + * Example: var(--some-token, var(--fallback, var(${tokens.someToken}))) + * + * @param value The CSS variable string to process + * @param propertyName The CSS property name this value is assigned to + * @param path The path in the style object + * @param TOKEN_REGEX The regex pattern to match token references + * @returns Array of token references found in the string + */ +export function extractTokensFromCssVars( + value: string, + propertyName: string, + path: string[] = [], + TOKEN_REGEX: RegExp, +): TokenReference[] { + const tokens: TokenReference[] = []; + + let testValue = value; + + // Direct token matches in the string + const directMatches = extractTokensFromText(testValue); + if (directMatches.length > 0) { + directMatches.forEach(match => { + testValue = testValue.replace(match, ''); // Remove direct matches from the string + tokens.push({ + property: propertyName, + token: match, + path, + }); + }); + } + + // we have an issue with duplicated calls. A direct match will match the whole string as would a token within a var part + // found by the regex, so we need to remove the direct matches from the string + + // Look for CSS var() patterns + const varPattern = /var\s*\(\s*([^,)]*),?\s*(.*?)\s*\)/g; + let match: RegExpExecArray | null; + + while ((match = varPattern.exec(testValue)) !== null) { + const fullMatch = match[0]; // The entire var(...) expression + const varName = match[1]; // The CSS variable name + const fallback = match[2]; // The fallback value, which might contain nested var() calls + + log(`Processing CSS var: ${fullMatch}`); + log(` - Variable name: ${varName}`); + log(` - Fallback: ${fallback}`); + + // Check if the variable name contains a token reference + const varNameTokens = extractTokensFromText(varName); + if (varNameTokens.length > 0) { + varNameTokens.forEach(token => { + tokens.push({ + property: propertyName, + token, + path, + }); + }); + } + + // If there's a fallback value, it might contain tokens or nested var() calls + if (fallback) { + // Recursively process the fallback + if (fallback.includes('var(')) { + const fallbackTokens = extractTokensFromCssVars(fallback, propertyName, path, TOKEN_REGEX); + tokens.push(...fallbackTokens); + } else { + // Check for direct token references in the fallback + const fallbackTokens = extractTokensFromText(fallback); + if (fallbackTokens.length > 0) { + fallbackTokens.forEach(token => { + tokens.push({ + property: propertyName, + token, + path, + }); + }); + } + } + } + } + + return tokens; +} diff --git a/packages/react-components/token-analyzer-preview/library/src/importAnalyzer.ts b/packages/react-components/token-analyzer-preview/library/src/importAnalyzer.ts new file mode 100644 index 00000000000000..bd76e6e9a15129 --- /dev/null +++ b/packages/react-components/token-analyzer-preview/library/src/importAnalyzer.ts @@ -0,0 +1,558 @@ +// importAnalyzer.ts +import { Project, Node, SourceFile, ImportDeclaration, Symbol, TypeChecker, SyntaxKind } from 'ts-morph'; +import { log } from './debugUtils.js'; +import { TokenReference, TOKEN_REGEX } from './types.js'; +import { getModuleSourceFile } from './moduleResolver.js'; +import { extractTokensFromCssVars } from './cssVarTokenExtractor.js'; +import { isTokenReference, extractTokensFromText } from './tokenUtils.js'; + +/** + * Represents a portion of a template expression + */ +interface TemplateSpan { + text: string; // The actual text content + isToken: boolean; // Whether this span is a token reference + isReference: boolean; // Whether this span is a reference to another variable + referenceName?: string; // The name of the referenced variable if isReference is true +} + +/** + * Represents a value imported from another module + */ +export interface ImportedValue { + value: string; + sourceFile: string; + isLiteral: boolean; + + // Enhanced fields for template processing + templateSpans?: TemplateSpan[]; // For template expressions with spans + resolvedTokens?: TokenReference[]; // Pre-extracted tokens from this value +} + +/** + * Analyzes imports in a source file to extract string values + */ +export async function analyzeImports(sourceFile: SourceFile, project: Project): Promise> { + const importedValues = new Map(); + const filePath = sourceFile.getFilePath(); + + log(`Analyzing imports in ${filePath}`); + + // Get TypeScript's type checker + const typeChecker = project.getTypeChecker(); + + // Process all import declarations + for (const importDecl of sourceFile.getImportDeclarations()) { + try { + // Process the import declaration + await processImportDeclaration(importDecl, sourceFile, project, importedValues, typeChecker); + } catch (err) { + log(`Error processing import: ${importDecl.getModuleSpecifierValue()}`, err); + } + } + + return importedValues; +} + +/** + * Process a single import declaration + */ +async function processImportDeclaration( + importDecl: ImportDeclaration, + sourceFile: SourceFile, + project: Project, + importedValues: Map, + typeChecker: TypeChecker, +): Promise { + const moduleSpecifier = importDecl.getModuleSpecifierValue(); + const containingFilePath = sourceFile.getFilePath(); + + // Use our module resolver to get the imported file + const importedFile = getModuleSourceFile(project, moduleSpecifier, containingFilePath); + + if (!importedFile) { + log(`Could not resolve module: ${moduleSpecifier}`); + return; + } + + // Process named imports (import { x } from 'module') + processNamedImports(importDecl, importedFile, project, importedValues, typeChecker); + + // Process default import (import x from 'module') + processDefaultImport(importDecl, importedFile, project, importedValues, typeChecker); +} + +/** + * Process named imports using TypeScript's type checker to follow re-exports + */ +function processNamedImports( + importDecl: ImportDeclaration, + importedFile: SourceFile, + project: Project, + importedValues: Map, + typeChecker: TypeChecker, +): void { + for (const namedImport of importDecl.getNamedImports()) { + const importName = namedImport.getName(); + const alias = namedImport.getAliasNode()?.getText() || importName; + + // Find the export's true source using TypeScript's type checker + const exportInfo = findExportDeclaration(importedFile, importName, typeChecker); + + if (exportInfo) { + const { declaration, sourceFile: declarationFile } = exportInfo; + + // Extract the value from the declaration + const valueInfo = extractValueFromDeclaration(declaration, typeChecker); + + if (valueInfo) { + importedValues.set(alias, { + value: valueInfo.value, + sourceFile: declarationFile.getFilePath(), + isLiteral: valueInfo.isLiteral, + templateSpans: valueInfo.templateSpans, + }); + + log(`Added imported value: ${alias} = ${valueInfo.value} from ${declarationFile.getFilePath()}`); + } + } + } +} + +/** + * Process default import using TypeScript's type checker + */ +function processDefaultImport( + importDecl: ImportDeclaration, + importedFile: SourceFile, + project: Project, + importedValues: Map, + typeChecker: TypeChecker, +): void { + const defaultImport = importDecl.getDefaultImport(); + if (!defaultImport) { + return; + } + + const importName = defaultImport.getText(); + + // Find the default export's true source + const exportInfo = findExportDeclaration(importedFile, 'default', typeChecker); + + if (exportInfo) { + const { declaration, sourceFile: declarationFile } = exportInfo; + + // Extract the value from the declaration + const valueInfo = extractValueFromDeclaration(declaration, typeChecker); + + if (valueInfo) { + importedValues.set(importName, { + value: valueInfo.value, + sourceFile: declarationFile.getFilePath(), + isLiteral: valueInfo.isLiteral, + templateSpans: valueInfo.templateSpans, + }); + + log(`Added default import: ${importName} = ${valueInfo.value} from ${declarationFile.getFilePath()}`); + } + } +} + +/** + * Find an export's original declaration using TypeScript's type checker + */ +function findExportDeclaration( + sourceFile: SourceFile, + exportName: string, + typeChecker: TypeChecker, +): { declaration: Node; sourceFile: SourceFile } | undefined { + try { + // Get the source file's symbol (represents the module) + const sourceFileSymbol = typeChecker.getSymbolAtLocation(sourceFile); + if (!sourceFileSymbol) { + log(`No symbol found for source file ${sourceFile.getFilePath()}`); + return undefined; + } + + // Get all exports from this module + const exports = typeChecker.getExportsOfModule(sourceFileSymbol); + if (!exports || exports.length === 0) { + log(`No exports found in module ${sourceFile.getFilePath()}`); + return undefined; + } + + // Find the specific export we're looking for + const exportSymbol = exports.find((symbol: Symbol) => symbol.getName() === exportName); + if (!exportSymbol) { + log(`Export symbol '${exportName}' not found in ${sourceFile.getFilePath()}`); + return undefined; + } + + // If this is an alias (re-export), get the original symbol + let resolvedSymbol: Symbol = exportSymbol; + if (exportSymbol.isAlias()) { + // we're ok type casting here because we know the symbol is an alias from the previous check but TS won't pick up on it + resolvedSymbol = typeChecker.getAliasedSymbol(exportSymbol) as Symbol; + log(`Resolved alias to: ${resolvedSymbol.getName()}`); + } + + // Get the value declaration from the resolved symbol + const valueDeclaration = resolvedSymbol.getValueDeclaration(); + if (!valueDeclaration) { + log(`No value declaration found for ${exportName}`); + + // Fallback to any declaration if value declaration is not available + const declarations = resolvedSymbol.getDeclarations(); + if (!declarations || declarations.length === 0) { + log(`No declarations found for ${exportName}`); + return undefined; + } + + const declaration = declarations[0]; + const declarationSourceFile = declaration.getSourceFile(); + + return { + declaration, + sourceFile: declarationSourceFile, + }; + } + + const declarationSourceFile = valueDeclaration.getSourceFile(); + + log( + `Found declaration for '${exportName}': ${valueDeclaration.getKindName()} in ${declarationSourceFile.getFilePath()}`, + ); + return { + declaration: valueDeclaration, + sourceFile: declarationSourceFile, + }; + } catch (err) { + log(`Error finding export declaration for ${exportName}:`, err); + return undefined; + } +} + +/** + * Extract string value from a declaration node + */ +function extractValueFromDeclaration( + declaration: Node, + typeChecker: TypeChecker, +): { value: string; isLiteral: boolean; templateSpans?: TemplateSpan[] } | undefined { + // Handle variable declarations + if (Node.isVariableDeclaration(declaration)) { + const initializer = declaration.getInitializer(); + return extractValueFromExpression(initializer, typeChecker); + } + // Handle export assignments (export default "value") + if (Node.isExportAssignment(declaration)) { + const expression = declaration.getExpression(); + return extractValueFromExpression(expression, typeChecker); + } + + // Handle named exports (export { x }) + if (Node.isExportSpecifier(declaration)) { + // Find the local symbol this specifier refers to + const name = declaration.getNameNode().getText(); + const sourceFile = declaration.getSourceFile(); + + // Find the local declaration with this name + for (const varDecl of sourceFile.getDescendantsOfKind(SyntaxKind.VariableDeclaration)) { + if (varDecl.getName() === name) { + const initializer = varDecl.getInitializer(); + return extractValueFromExpression(initializer, typeChecker); + } + } + } + + return undefined; +} + +/** + * Extract value from an expression node with enhanced template literal handling + */ +function extractValueFromExpression( + expression: Node | undefined, + typeChecker: TypeChecker, +): + | { + value: string; + isLiteral: boolean; + templateSpans?: TemplateSpan[]; + } + | undefined { + if (!expression) { + return undefined; + } + + if (Node.isStringLiteral(expression)) { + return { + value: expression.getLiteralValue(), + isLiteral: true, + }; + } else if (Node.isTemplateExpression(expression)) { + // Process the template head and spans fully + const head = expression.getHead().getLiteralText(); + const spans = expression.getTemplateSpans(); + + let fullValue = head; + const templateSpans: TemplateSpan[] = []; + + // Add head as a non-token span if it's not empty + if (head) { + templateSpans.push({ + text: head, + isToken: false, + isReference: false, + }); + } + + // Process each span in the template expression + for (const span of spans) { + const spanExpr = span.getExpression(); + const spanText = spanExpr.getText(); + const literal = span.getLiteral().getLiteralText(); + + // Handle different types of expressions in template spans + if (Node.isPropertyAccessExpression(spanExpr) && isTokenReference(spanExpr)) { + // Direct token reference in template span + templateSpans.push({ + text: spanText, + isToken: true, + isReference: false, + }); + fullValue += spanText; + } else if (Node.isIdentifier(spanExpr)) { + // Potential reference to another variable + templateSpans.push({ + text: spanText, + isToken: false, + isReference: true, + referenceName: spanText, + }); + fullValue += spanText; + } else { + // Other expression types - try to resolve recursively + const resolvedExpr = extractValueFromExpression(spanExpr, typeChecker); + if (resolvedExpr) { + if (resolvedExpr.templateSpans) { + // If it has its own spans, include them + templateSpans.push(...resolvedExpr.templateSpans); + } else { + // Otherwise add the value + templateSpans.push({ + text: resolvedExpr.value, + isToken: false, + isReference: false, + }); + } + fullValue += resolvedExpr.value; + } else { + // Fallback to the raw text if we can't resolve + templateSpans.push({ + text: spanText, + isToken: false, + isReference: false, + }); + fullValue += spanText; + } + } + + // Add the literal part that follows the expression + if (literal) { + templateSpans.push({ + text: literal, + isToken: false, + isReference: false, + }); + fullValue += literal; + } + } + + return { + value: fullValue, + isLiteral: true, + templateSpans, + }; + } else if (Node.isIdentifier(expression)) { + // Try to resolve the identifier to its value + const symbol = expression.getSymbol(); + if (!symbol) { + return { + value: expression.getText(), + isLiteral: false, + }; + } + + // Get the declaration of this identifier + const decl = symbol.getValueDeclaration() || symbol.getDeclarations()?.[0]; + if (!decl) { + return { + value: expression.getText(), + isLiteral: false, + }; + } + + // If it's a variable declaration, get its initializer + if (Node.isVariableDeclaration(decl)) { + const initializer = decl.getInitializer(); + if (initializer) { + // Recursively resolve the initializer + return extractValueFromExpression(initializer, typeChecker); + } + } + + return { + value: expression.getText(), + isLiteral: false, + }; + } else if (Node.isPropertyAccessExpression(expression)) { + // Handle tokens.xyz or other property access + return { + value: expression.getText(), + isLiteral: false, + }; + } else if (Node.isNoSubstitutionTemplateLiteral(expression)) { + return { + value: expression.getLiteralValue(), + isLiteral: true, + }; + } + + // Default case for unhandled expression types + return undefined; +} + +/** + * Process string tokens in imported values + */ +export function processImportedStringTokens( + importedValues: Map, + propertyName: string, + value: string, + path: string[] = [], + tokenRegex: RegExp = TOKEN_REGEX, +): TokenReference[] { + const tokens: TokenReference[] = []; + + // Check if the value is an imported value reference + if (importedValues.has(value)) { + const importedValue = importedValues.get(value)!; + + // If we've already pre-resolved tokens for this value, use them + if (importedValue.resolvedTokens) { + return importedValue.resolvedTokens.map(token => ({ + ...token, + property: propertyName, // Update property name for current context + path: path, // Update path for current context + })); + } + + if (importedValue.isLiteral) { + if (importedValue.templateSpans) { + // Process template spans specially + for (const span of importedValue.templateSpans) { + if (span.isToken) { + // Direct token reference in span + tokens.push({ + property: propertyName, + token: span.text, + path, + isVariableReference: true, + sourceFile: importedValue.sourceFile, + }); + } else if (span.isReference && span.referenceName && importedValues.has(span.referenceName)) { + // Reference to another imported value - process recursively + const spanTokens = processImportedStringTokens( + importedValues, + propertyName, + span.referenceName, + path, + tokenRegex, + ); + tokens.push(...spanTokens); + } else if (span.text.includes('var(')) { + // Check for CSS variables in the span text + const cssVarTokens = extractTokensFromCssVars(span.text, propertyName, path, tokenRegex); + cssVarTokens.forEach(token => { + tokens.push({ + ...token, + isVariableReference: true, + sourceFile: importedValue.sourceFile, + }); + }); + } else { + // Check for direct token matches in non-reference spans + const matches = extractTokensFromText(span.text); + if (matches.length > 0) { + matches.forEach(match => { + tokens.push({ + property: propertyName, + token: match, + path, + isVariableReference: true, + sourceFile: importedValue.sourceFile, + }); + }); + } + } + } + } else { + // Standard processing for literals without spans + // First, check for direct token references + const matches = extractTokensFromText(importedValue.value); + if (matches.length > 0) { + matches.forEach(match => { + tokens.push({ + property: propertyName, + token: match, + path, + isVariableReference: true, + sourceFile: importedValue.sourceFile, + }); + }); + } else if (importedValue.value.includes('var(')) { + // Then check for CSS variable patterns + const cssVarTokens = extractTokensFromCssVars(importedValue.value, propertyName, path, tokenRegex); + cssVarTokens.forEach(token => { + tokens.push({ + ...token, + isVariableReference: true, + sourceFile: importedValue.sourceFile, + }); + }); + } + } + } else { + // Non-literal values (like property access expressions) + if (isTokenReference(importedValue.value)) { + tokens.push({ + property: propertyName, + token: importedValue.value, + path, + isVariableReference: true, + sourceFile: importedValue.sourceFile, + }); + } else { + // Check for any token references in the value + const matches = extractTokensFromText(importedValue.value); + if (matches.length > 0) { + matches.forEach(match => { + tokens.push({ + property: propertyName, + token: match, + path, + isVariableReference: true, + sourceFile: importedValue.sourceFile, + }); + }); + } + } + } + + // Cache the resolved tokens for future use + importedValue.resolvedTokens = tokens.map(token => ({ ...token })); + } + + return tokens; +} diff --git a/packages/react-components/token-analyzer-preview/library/src/index.ts b/packages/react-components/token-analyzer-preview/library/src/index.ts index 58a4d0a3594b5c..c67bde652515c9 100644 --- a/packages/react-components/token-analyzer-preview/library/src/index.ts +++ b/packages/react-components/token-analyzer-preview/library/src/index.ts @@ -2,6 +2,7 @@ import { Project } from 'ts-morph'; import { promises as fs } from 'fs'; import { relative } from 'path'; +import { format } from 'prettier'; import { findStyleFiles } from './fileOperations.js'; import { analyzeFile } from './astAnalyzer.js'; import { AnalysisResults, FileAnalysis } from './types.js'; @@ -48,7 +49,15 @@ async function analyzeProjectStyles( if (outputFile) { await measureAsync('write output file', async () => { - await fs.writeFile(outputFile, JSON.stringify(results, null, 2), 'utf8'); + const formatted = format(JSON.stringify(sortObjectByKeys(results), null, 2), { + parser: 'json', + printWidth: 120, + tabWidth: 2, + singleQuote: true, + trailingComma: 'all', + arrowParens: 'avoid', + }); + await fs.writeFile(outputFile, formatted, 'utf8'); console.log(`Analysis written to ${outputFile}`); }); } @@ -60,6 +69,21 @@ async function analyzeProjectStyles( } } +/** + * Sorts an object by its keys alphabetically + * + * @param obj Object to sort + * @returns New object with the same properties, sorted by keys + */ +function sortObjectByKeys(obj: Record): Record { + return Object.keys(obj) + .sort() + .reduce((sorted: Record, key: string) => { + sorted[key] = obj[key]; + return sorted; + }, {}); +} + function countTokens(analysis: FileAnalysis): number { let count = 0; Object.values(analysis.styles).forEach(_value => { diff --git a/packages/react-components/token-analyzer-preview/library/src/moduleResolver.ts b/packages/react-components/token-analyzer-preview/library/src/moduleResolver.ts new file mode 100644 index 00000000000000..c41656a6b4424f --- /dev/null +++ b/packages/react-components/token-analyzer-preview/library/src/moduleResolver.ts @@ -0,0 +1,204 @@ +// moduleResolver.ts +import * as ts from 'typescript'; +import * as path from 'path'; +import * as fs from 'fs'; +import { Project, SourceFile } from 'ts-morph'; +import { log } from './debugUtils.js'; + +// Create a wrapper around TypeScript APIs for easier mocking +export const tsUtils = { + resolveModuleName: ( + moduleName: string, + containingFile: string, + compilerOptions: ts.CompilerOptions, + host: ts.ModuleResolutionHost, + ) => ts.resolveModuleName(moduleName, containingFile, compilerOptions, host), + + getFileSize: (filePath: string) => ts.sys.getFileSize?.(filePath), + + fileExists: (filePath: string) => ts.sys.fileExists(filePath), +}; + +// Cache for resolved module paths +export const modulePathCache = new Map(); + +// Cache for resolved source files +export const resolvedFilesCache = new Map(); + +/** + * Creates a cache key for module resolution + */ +function createCacheKey(moduleSpecifier: string, containingFile: string): string { + return `${containingFile}:${moduleSpecifier}`; +} + +/** + * Verifies a resolved file path actually exists + */ +function verifyFileExists(filePath: string | undefined | null): boolean { + if (!filePath) { + return false; + } + + try { + // Use TypeScript's file system abstraction for testing compatibility + return tsUtils.fileExists(filePath); + } catch (e) { + // If that fails, try Node's fs as fallback + try { + return fs.existsSync(filePath); + } catch (nestedE) { + return false; + } + } +} + +/** + * Resolves a module specifier to an absolute file path using TypeScript's resolution + * + * @param project TypeScript project + * @param moduleSpecifier The module to resolve (e.g., './utils', 'react') + * @param containingFile The file containing the import + * @returns The absolute file path or null if it can't be resolved + */ +export function resolveModulePath(project: Project, moduleSpecifier: string, containingFile: string): string | null { + const cacheKey = createCacheKey(moduleSpecifier, containingFile); + + // Check cache first + if (modulePathCache.has(cacheKey)) { + return modulePathCache.get(cacheKey)!; + } + + // For relative paths, try a simple path resolution first + if (moduleSpecifier.startsWith('.')) { + try { + const basePath = path.dirname(containingFile); + const extensions = ['.ts', '.tsx', '.js', '.jsx', '.d.ts']; + + // Check if the module specifier already has a valid extension + const hasExtension = extensions.some(ext => moduleSpecifier.endsWith(ext)); + + // 1. If it has an extension, try the exact path first + if (hasExtension) { + const exactPath = path.resolve(basePath, moduleSpecifier); + if (verifyFileExists(exactPath)) { + modulePathCache.set(cacheKey, exactPath); + return exactPath; + } + } + + // 2. Try with added extensions (for paths without extension or if exact path failed) + if (!hasExtension) { + for (const ext of extensions) { + const candidatePath = path.resolve(basePath, moduleSpecifier + ext); + if (verifyFileExists(candidatePath)) { + modulePathCache.set(cacheKey, candidatePath); + return candidatePath; + } + } + } + + // 3. Try as directory with index file + const dirPath = hasExtension + ? path.resolve( + basePath, + path.dirname(moduleSpecifier), + path.basename(moduleSpecifier, path.extname(moduleSpecifier)), + ) + : path.resolve(basePath, moduleSpecifier); + + for (const ext of extensions) { + const candidatePath = path.resolve(dirPath, 'index' + ext); + if (verifyFileExists(candidatePath)) { + modulePathCache.set(cacheKey, candidatePath); + return candidatePath; + } + } + } catch (e) { + // Fall through to TypeScript's module resolution + } + } + + // Use TypeScript's module resolution API + const result = tsUtils.resolveModuleName( + moduleSpecifier, + containingFile, + project.getCompilerOptions() as ts.CompilerOptions, + ts.sys, + ); + + // Validate and cache the result + if (result.resolvedModule) { + const resolvedPath = result.resolvedModule.resolvedFileName; + + // Verify the file actually exists + if (verifyFileExists(resolvedPath)) { + modulePathCache.set(cacheKey, resolvedPath); + return resolvedPath; + } + } + + // Cache negative result + log(`Could not resolve module: ${moduleSpecifier} from ${containingFile}`); + modulePathCache.set(cacheKey, null); + return null; +} + +/** + * Gets a source file for a module specifier, resolving and adding it if needed + * + * @param project TypeScript project + * @param moduleSpecifier The module to resolve (e.g., './utils', 'react') + * @param containingFile The file containing the import + * @returns The resolved source file or null if it can't be resolved + */ +export function getModuleSourceFile( + project: Project, + moduleSpecifier: string, + containingFile: string, +): SourceFile | null { + log(`Resolving module: ${moduleSpecifier} from ${containingFile}`); + + // Step 1: Try to resolve the module to a file path + const resolvedPath = resolveModulePath(project, moduleSpecifier, containingFile); + if (!resolvedPath) { + log(`Could not resolve module: ${moduleSpecifier}`); + return null; + } + + // Step 2: Check if we already have this file + if (resolvedFilesCache.has(resolvedPath)) { + return resolvedFilesCache.get(resolvedPath)!; + } + + // Step 3: Get or add the file to the project + try { + // First try to get file if it's already in the project + let sourceFile = project.getSourceFile(resolvedPath); + + // If not found, add it + if (!sourceFile) { + sourceFile = project.addSourceFileAtPath(resolvedPath); + log(`Added source file: ${resolvedPath}`); + } + + // Cache the result + resolvedFilesCache.set(resolvedPath, sourceFile); + return sourceFile; + } catch (error) { + log(`Error adding source file: ${resolvedPath}`, error); + return null; + } +} + +/** + * Clears the module resolution caches + * Useful for testing or when analyzing multiple projects + */ +export function clearModuleCache(): void { + modulePathCache.clear(); + resolvedFilesCache.clear(); +} + +// Export for testing +export { verifyFileExists }; diff --git a/packages/react-components/token-analyzer-preview/library/src/tokenUtils.ts b/packages/react-components/token-analyzer-preview/library/src/tokenUtils.ts new file mode 100644 index 00000000000000..12cf9b02e9d6dc --- /dev/null +++ b/packages/react-components/token-analyzer-preview/library/src/tokenUtils.ts @@ -0,0 +1,94 @@ +// tokenUtils.ts +import { Node, Symbol } from 'ts-morph'; +import { TOKEN_REGEX } from './types.js'; + +/** + * Centralizes token detection logic to make future changes easier + * @param textOrNode The text or Node to check for token references + * @returns true if the text/node contains a token reference + */ +export function isTokenReference(textOrNode: string | Node | Symbol): boolean { + // If we have a Node or Symbol, extract the text to check + let text: string; + + if (typeof textOrNode === 'string') { + text = textOrNode; + } else if (Node.isNode(textOrNode)) { + text = textOrNode.getText(); + } else if (textOrNode instanceof Symbol) { + // For symbols, we need to check the declarations + const declarations = textOrNode.getDeclarations(); + if (!declarations || declarations.length === 0) { + return false; + } + + // Get text from the first declaration + text = declarations[0].getText(); + } else { + return false; + } + // IMPORTANT: Reset lastIndex to prevent issues with the global flag + TOKEN_REGEX.lastIndex = 0; + const test = TOKEN_REGEX.test(text); + return test; +} + +/** + * Extracts all token references from a text string or Node + * @param textOrNode The text or Node to extract tokens from + * @returns Array of token reference strings + */ +export function extractTokensFromText(textOrNode: string | Node | Symbol): string[] { + // If we have a Node or Symbol, extract the text to check + let text: string; + + if (typeof textOrNode === 'string') { + text = textOrNode; + } else if (Node.isNode(textOrNode)) { + text = textOrNode.getText(); + } else if (textOrNode instanceof Symbol) { + // For symbols, we need to check the declarations + const declarations = textOrNode.getDeclarations(); + if (!declarations || declarations.length === 0) { + return []; + } + + // Get text from the first declaration + text = declarations[0].getText(); + } else { + return []; + } + + const matches = text.match(TOKEN_REGEX); + return matches || []; +} + +/** + * Maps shorthand function names to the CSS properties they affect + * @param functionName The name of the shorthand function (e.g., "borderColor" or "shorthands.borderColor") + * @returns Array of CSS property names affected by this shorthand + */ +export function getPropertiesForShorthand(functionName: string): string[] { + const shorthandMap: Record = { + // Border shorthands + borderColor: ['borderTopColor', 'borderRightColor', 'borderBottomColor', 'borderLeftColor'], + border: ['borderWidth', 'borderStyle', 'borderColor'], + borderRadius: ['borderTopLeftRadius', 'borderTopRightRadius', 'borderBottomRightRadius', 'borderBottomLeftRadius'], + + // Padding/margin shorthands + padding: ['paddingTop', 'paddingRight', 'paddingBottom', 'paddingLeft'], + margin: ['marginTop', 'marginRight', 'marginBottom', 'marginLeft'], + + // Other common shorthands + flex: ['flexGrow', 'flexShrink', 'flexBasis'], + gap: ['rowGap', 'columnGap'], + overflow: ['overflowX', 'overflowY'], + gridArea: ['gridRowStart', 'gridColumnStart', 'gridRowEnd', 'gridColumnEnd'], + inset: ['top', 'right', 'bottom', 'left'], + }; + + // Extract base function name if it's a qualified name (e.g., shorthands.borderColor -> borderColor) + const baseName = functionName.includes('.') ? functionName.split('.').pop() : functionName; + + return baseName && shorthandMap[baseName!] ? shorthandMap[baseName!] : []; +}