diff --git a/package.json b/package.json index a283b3e26..61ca629f9 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,8 @@ "@fluentui/react-migration-v8-v9": "^9.6.23", "@fluentui/react-shared-contexts": "^9.7.2", "@fluentui/scheme-utilities": "^8.3.58", + "@fluentui/semantic-tokens": "0.0.0-nightly-20250501-1704.1", + "@griffel/react": "^1.5.14", "@griffel/shadow-dom": "~0.2.0", "@nx/devkit": "20.8.2", "@nx/eslint": "20.8.2", @@ -71,10 +73,12 @@ "@testing-library/user-event": "14.6.1", "@types/jest": "29.5.14", "@types/node": "20.14.9", + "@types/prettier": "^2.6.2", "@types/react": "18.3.1", "@types/react-dom": "18.3.0", "@types/react-virtualized-auto-sizer": "^1.0.1", "@types/react-window": "^1.8.5", + "@types/yargs": "^17.0.33", "beachball": "^2.33.2", "eslint": "9.26.0", "eslint-config-prettier": "10.1.5", @@ -98,11 +102,13 @@ "storybook": "7.6.20", "stylelint": "^15.10.3", "syncpack": "^9.8.6", + "ts-morph": "24.0.0", "ts-node": "10.9.2", "tslib": "^2.3.0", "typescript": "5.7.3", "typescript-eslint": "8.32.1", - "verdaccio": "6.1.2" + "verdaccio": "6.1.2", + "yargs": "^17.7.2" }, "dependencies": {}, "nx": { diff --git a/packages/token-analyzer/.prettierrc b/packages/token-analyzer/.prettierrc new file mode 100644 index 000000000..0981b7cc0 --- /dev/null +++ b/packages/token-analyzer/.prettierrc @@ -0,0 +1,4 @@ +{ + "singleQuote": true, + "printWidth": 120 +} diff --git a/packages/token-analyzer/README.md b/packages/token-analyzer/README.md index ba0a84e14..829645f17 100644 --- a/packages/token-analyzer/README.md +++ b/packages/token-analyzer/README.md @@ -1,11 +1,304 @@ -# token-analyzer +# Design Token Usage Analyzer -This library was generated with [Nx](https://nx.dev). +A static analysis tool that scans your project's style files to track and analyze design token usage. The analyzer helps identify where and how design tokens are being used across your codebase, making it easier to maintain consistency and track token adoption. The data from this tool can also be used to create other tools like theme designers. -## Building +### How it works -Run `nx build token-analyzer` to build the library. +The tool first scans for the common pattern of `styles` or `style` being in the file name (along with common file extensions). From there it checks all imports to see if there are imports of known tokens. Currently, the list of known tokens and packages are internally maintained but we could easily expose this for extension libraries as well. See [knownTokenImportsAndModules](./src/types.ts#L65) in `types.ts` for the current list. This analysis isn't just done for direct imports but for any re-exports, new variable declarations, template expressions values, etc. This hopefully covers a wide range of scenarios the tool might encounter in code but it's possible there's more edge cases. Please report any issues you find so we can fix them and add new tests. Once this mapping is done, the tool scans for `makeStyles`, `makeResetStyles` and `mergeStyles` to build a comprehensive picture of what styles use which tokens, what meta data is considered when applying the styles and what properties they're applied to. As a result, this tool is targeted towards Griffel based styles for now. Since this tool works off the AST maps the usage of tokens and imports back to their symbols instead of just string analysis which we've found to be quite robust. Once analysis is complete, it outputs a JSON file with the mappings. By default it will produce a single analysis file for a given run. Multiple files are under an object key with their relative path within the JSON file. -## Running unit tests +## TODO -Run `nx test token-analyzer` to execute the unit tests via [Jest](https://jestjs.io). +- add config to point to custom prettier config for file output. +- add tests for findTsConfigPath +- Remove `extractTokensFromText` as we're only using it to help with `getPropertiesForShorthand`, we should leverage the existing analysis for this +- update contributing doc with info about version management +- Update test to return promise instead of async/await function. +- Add ability to customize glob used to find style files +- Add ability to add known tokens +- Read gitignore from target dir and use that for ignore if we find one. (currently hard coded). +- Add 'thorough' or 'complete' mode that doesn't filter files based on `style` or styles` in the name. +- + +## Installation + +```bash +npm install --save-dev @fluentui-contrib/token-analyzer +``` + +or + +```bash +yarn add @fluentui-contrib/token-analyzer -D +``` + +## Usage + +### Command Line Interface + +Run the style analysis tool: + +```bash +npm run analyze-tokens [options] +``` + +### Options + +| Option | Alias | Type | Default | Description | +| ----------- | ----- | ------- | ----------------------- | ------------------------------------- | +| `--root` | `-r` | string | `./src` | Root directory to analyze | +| `--output` | `-o` | string | `./token-analysis.json` | Output file path for results | +| `--debug` | `-d` | boolean | `false` | Enable debug mode for verbose logging | +| `--perf` | `-p` | boolean | `false` | Enable performance tracking | +| `--help` | `-h` | - | - | Show help information | +| `--version` | - | - | - | Show version number | + +### Examples + +**Basic usage (uses defaults):** + +```bash +npm run analyze-tokens +``` + +**Custom directory and output:** + +```bash +npm run analyze-tokens -- --root ./components --output ./analysis-results.json +``` + +**With debugging and performance tracking:** + +```bash +npm run analyze-tokens -- --root ./src/components --debug --perf +``` + +### Getting Help + +View all available options and examples: + +```bash +npm run analyze-tokens --help +# or +npm run analyze-tokens -h +``` + +View version information: + +```bash +npm run analyze-tokens --version +``` + +### Output + +The tool will display progress information and a summary: + +``` +Starting analysis of ./src +Output will be written to ./token-analysis.json +Debug mode enabled +Performance tracking enabled + +Analysis complete! +Processed 23 files containing styles +Found 156 token references +``` + +Results are saved as JSON to the specified output file, containing detailed analysis of each file's style usage and token references. + +### Programmatic Usage + +```typescript +import { analyzeProjectStyles } from '@fluentui-contrib/token-analyzer'; + +async function analyze() { + const results = await analyzeProjectStyles('./src', './analysis.json', { + debug: true, + perf: true, + }); + + console.log(`Analyzed ${Object.keys(results).length} files`); +} +``` + +## Example JSON Output + +Below is a simplification of styles output that the tool might produce. Note that the `assignedVariables` field corresponds to the key name under `styleConditions`. + +```json +{ + "useButtonStyles.styles.ts": { + "styles": { + "useRootBaseClassName": { + "resetStyles": { + "tokens": [ + { + "property": "backgroundColor", + "token": ["tokens.colorNeutralBackground1"], + "path": ["backgroundColor"] + }, + { + "property": "color", + "token": ["semanticTokens.cornerFlyoutRest"], + "path": ["color"] + }, + { + "property": "border", + "token": ["tokens.strokeWidthThin"], + "path": ["border"] + }, + { + "property": "border", + "token": ["tokens.colorNeutralStroke1"], + "path": ["border"] + }, + { + "property": "fontFamily", + "token": ["textStyleAiHeaderFontfamily"], + "path": ["fontFamily"] + }, + { + "property": "padding", + "token": ["tokens.spacingHorizontalM"], + "path": ["padding"] + }, + { + "property": "borderRadius", + "token": ["tokens.borderRadiusMedium"], + "path": ["borderRadius"] + }, + { + "property": "fontSize", + "token": ["tokens.fontSizeBase300"], + "path": ["fontSize"] + }, + { + "property": "fontWeight", + "token": ["tokens.fontWeightSemibold"], + "path": ["fontWeight"] + }, + { + "property": "lineHeight", + "token": ["tokens.lineHeightBase300"], + "path": ["lineHeight"] + }, + { + "property": "transitionDuration", + "token": ["tokens.durationFaster"], + "path": ["transitionDuration"] + }, + { + "property": "transitionTimingFunction", + "token": ["tokens.curveEasyEase"], + "path": ["transitionTimingFunction"] + } + ], + "nested": { + "':hover'": { + "tokens": [ + { + "property": "backgroundColor", + "token": ["cornerCtrlLgHoverRaw"], + "path": ["':hover'", "backgroundColor"] + }, + { + "property": "borderColor", + "token": ["ctrlLinkForegroundBrandHover"], + "path": ["':hover'", "borderColor"] + }, + { + "property": "color", + "token": ["tokens.colorNeutralForeground1Hover"], + "path": ["':hover'", "color"] + } + ] + } + }, + "isResetStyles": true, + "assignedVariables": ["rootBaseClassName"] + } + }, + "useRootDisabledStyles": { + "base": { + "tokens": [ + { + "property": "backgroundColor", + "token": ["tokens.colorNeutralBackgroundDisabled"], + "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"] + } + ], + "assignedVariables": ["rootDisabledStyles"] + } + } + }, + "metadata": { + "styleConditions": { + "rootBaseClassName": { + "isBase": true, + "slotName": "root" + }, + "rootDisabledStyles.base": { + "conditions": ["(disabled || disabledFocusable)"], + "slotName": "root" + } + } + } + } +} +``` + +## Configuration + +The analyzer identifies style files based on naming conventions. By default, it looks for: + +- Files containing `style` or `styles` in the name +- Files with extensions: `.ts`, `.tsx`, `.js`, `.jsx`, `.mjs` + +### Debug Configuration + +Debug and performance tracking can be configured via: + +1. CLI flags (as shown above) +2. Programmatic options when calling `analyzeProjectStyles` + +## Development + +### Running Tests + +```bash +npm test +``` + +### Building + +```bash +npm run build +``` + +## License + +This project is licensed under the MIT License - see the LICENSE file for details. diff --git a/packages/token-analyzer/eslint.config.cjs b/packages/token-analyzer/eslint.config.cjs deleted file mode 100644 index 9d2af7a3d..000000000 --- a/packages/token-analyzer/eslint.config.cjs +++ /dev/null @@ -1,19 +0,0 @@ -const baseConfig = require('../../eslint.config.js'); - -module.exports = [ - ...baseConfig, - { - files: ['**/*.json'], - rules: { - '@nx/dependency-checks': [ - 'error', - { - ignoredFiles: ['{projectRoot}/eslint.config.{js,cjs,mjs}'], - }, - ], - }, - languageOptions: { - parser: require('jsonc-eslint-parser'), - }, - }, -]; diff --git a/packages/token-analyzer/eslint.config.js b/packages/token-analyzer/eslint.config.js index 2be3ed1ba..a40a07939 100644 --- a/packages/token-analyzer/eslint.config.js +++ b/packages/token-analyzer/eslint.config.js @@ -1,7 +1,34 @@ const baseConfig = require('../../eslint.config.js'); +const newBaseConfig = baseConfig.map((config) => { + // Find the specific configuration entry that contains the @nx/dependency-checks rule + if (config.rules?.['@nx/dependency-checks']) { + // Create a new config object with the extended ignoredFiles array + return { + ...config, + rules: { + ...config.rules, + '@nx/dependency-checks': [ + config.rules['@nx/dependency-checks'][0], + { + ...config.rules['@nx/dependency-checks'][1], + ignoredFiles: [ + ...config.rules['@nx/dependency-checks'][1].ignoredFiles, + // Exclude our test files from the dep checks + '{projectRoot}/**/__tests__/test-files/**', + ], + }, + ], + }, + }; + } + + // Return other config entries unchanged + return config; +}); + module.exports = [ - ...baseConfig, + ...newBaseConfig, { files: ['**/*.ts', '**/*.tsx'], // Override or add rules here diff --git a/packages/token-analyzer/importAnalyzerFlow.md b/packages/token-analyzer/importAnalyzerFlow.md new file mode 100644 index 000000000..37f514e48 --- /dev/null +++ b/packages/token-analyzer/importAnalyzerFlow.md @@ -0,0 +1,25 @@ +- analyze imports +- split into 3 paths to analyze named, default and namespace imports +- converge back to same path and analyze each import +- determine if it's a direct import of a token, if it is, log it and move on +- if it's not a direct token import, but is relative, we should analyze any aliases or value delcarations. We can also use `isExternalModuleNameRelative` to figure out if we should keep digging for aliases. So even if, for some reason, we have an alias but it's past the relative boundary, we can exit processing. We shouldn't dig into modules here. +- if it's not a direct token import, and is not relative, we know we've hit a boundary and can stop processing. + +```mermaid +flowchart TD + A[Start: Analyze Imports] --> B{Import Type?} + B --> |Named| C[Analyze Named Import] + B --> |Default| D[Analyze Default Import] + B --> |Namespace| E[Analyze Namespace Import] + C --> F[Converge Paths] + D --> F + E --> F + F[Analyze Each Import] --> G{Direct Token Import?} + G --> |Yes| H[Log & Continue] + G --> |No| I{Relative Import?} + I --> |Yes| J[Inspect Aliases/Value Declarations
Use isExternalModuleNameRelative] + J --> K{Within Relative Boundary?} + K --> |Yes| F + K --> |No| L[Exit Processing] + I --> |No| M[Stop Processing] +``` diff --git a/packages/token-analyzer/package.json b/packages/token-analyzer/package.json index 1df9080d5..9a6286ea4 100644 --- a/packages/token-analyzer/package.json +++ b/packages/token-analyzer/package.json @@ -3,6 +3,20 @@ "version": "0.0.1", "main": "./src/index.js", "types": "./src/index.d.ts", - "dependencies": {}, + "dependencies": { + "ts-morph": "^24.0.0", + "typescript": "5.7.3", + "prettier": "^2.6.2", + "@griffel/react": "^1.5.14", + "yargs": "^17.7.2" + }, + "scripts": { + "analyze-tokens": "(cd ../.. && nx build token-analyzer) && node ../../dist/packages/token-analyzer/lib-commonjs/index.js", + "test": "jest", + "test:debug": "node --loader ts-node/esm --inspect-brk node_modules/.bin/jest --runInBand" + }, + "bin": { + "token-analyzer": "./lib-commonjs/index.js" + }, "private": true } diff --git a/packages/token-analyzer/src/__tests__/analyzer.test.ts b/packages/token-analyzer/src/__tests__/analyzer.test.ts new file mode 100644 index 000000000..72f806bf3 --- /dev/null +++ b/packages/token-analyzer/src/__tests__/analyzer.test.ts @@ -0,0 +1,84 @@ +import { Project } from 'ts-morph'; +import { analyzeFile } from '../astAnalyzer.js'; +import { sampleStyles } from './sample-styles.js'; +import * as path from 'path'; +import * as fs from 'fs/promises'; +import { findTsConfigPath } from '../findTsConfigPath'; + +describe('Token Analyzer', () => { + let project: Project; + let tempFilePath: string; + + beforeAll(async () => { + // Create temp directory for test files + const tempDir = path.join(__dirname, 'temp-test-files'); + await fs.mkdir(tempDir, { recursive: true }); + tempFilePath = path.join(tempDir, 'test-styles.ts'); + await fs.writeFile(tempFilePath, sampleStyles); + + project = new Project({ + tsConfigFilePath: findTsConfigPath() || '', + skipAddingFilesFromTsConfig: true, + skipFileDependencyResolution: false, + }); + }); + + afterAll(async () => { + // Cleanup temp files + const tempDir = path.join(__dirname, 'temp-test-files'); + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + it('should analyze styles and find tokens', async () => { + const analysis = await analyzeFile(tempFilePath, project); + + // Verify the structure matches what we expect + expect(analysis).toHaveProperty('styles'); + expect(analysis).toHaveProperty('metadata'); + + const { styles, metadata } = analysis; + + // Verify root styles + expect(styles.useStyles.root.tokens).toContainEqual( + expect.objectContaining({ + property: 'color', + 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( + expect.objectContaining({ + property: 'color', + token: ['tokens.colorNeutralForeground2'], + }) + ); + + // Verify focus function styles + expect(styles.useStyles.focusIndicator.tokens).toEqual([]); + const focusStyle = styles.useStyles.focusIndicator.nested?.[':focus']; + + expect(focusStyle?.tokens[0]).toEqual({ + path: [':focus', 'textDecorationColor'], + property: 'textDecorationColor', + token: ['tokens.colorStrokeFocus2'], + }); + + // Verify metadata for conditional styles + expect(metadata.styleConditions['styles.large']).toEqual({ + conditions: ["size === 'large'"], + slotName: 'root', + }); + expect(metadata.styleConditions['styles.disabled']).toEqual({ + conditions: ['disabled'], + slotName: 'root', + }); + expect(metadata.styleConditions['styles.large'].conditions).toContain("size === 'large'"); + }); +}); diff --git a/packages/token-analyzer/src/__tests__/cssVarE2E.test.ts b/packages/token-analyzer/src/__tests__/cssVarE2E.test.ts new file mode 100644 index 000000000..e5616d9f0 --- /dev/null +++ b/packages/token-analyzer/src/__tests__/cssVarE2E.test.ts @@ -0,0 +1,307 @@ +// cssVarE2E.test.ts +import { Project } from 'ts-morph'; +import { analyzeFile } from '../astAnalyzer.js'; +import * as path from 'path'; +import * as fs from 'fs/promises'; +import { findTsConfigPath } from '../findTsConfigPath'; + +// Test file contents +const cssVarsStyleFile = ` +import { makeStyles } from '@griffel/react'; +import { tokens } from '@fluentui/react-theme'; +import { colorPrimary, colorSecondary, nestedFallbackVar, complexCssVar } from './tokenVars'; +import { ctrlLinkForegroundBrandHover } from '@fluentui/semantic-tokens'; + +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(\${ctrlLinkForegroundBrandHover}, \${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: findTsConfigPath() || '', + 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'], + }) + ); + + // 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'], + }) + ); + + // 5. Verify nested CSS variable with token + expect(useStyles.nestedCssVar.tokens.length).toBe(1); + expect(useStyles.nestedCssVar.tokens).toContainEqual( + expect.objectContaining({ + property: 'background', + token: ['ctrlLinkForegroundBrandHover', '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'], + }) + ); + + // 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'], + }), + expect.objectContaining({ + token: ['tokens.colorNeutralBackground1'], + }), + ]) + ); + }); +}); + +// 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 + marginLeft:someOtherMargin + } + }); + + export default useStyles; + ` + ); + + // Initialize project + project = new Project({ + tsConfigFilePath: findTsConfigPath() || '', + 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'], + }), + // Import of CSS var with token + expect.objectContaining({ + property: 'backgroundColor', + token: ['tokens.colorBrandPrimary'], + }), + // Import of nested CSS var with token + expect.objectContaining({ + property: 'border', + token: ['tokens.colorBrandSecondary'], + }), + // Multiple tokens from a complex var + expect.objectContaining({ + property: 'padding', + token: ['tokens.colorBrandPrimary', 'tokens.colorBrandSecondary'], + }), + expect.objectContaining({ + property: 'marginRight', + token: ['tokens.spacingHorizontalXXL'], + }), + expect.objectContaining({ + property: 'marginLeft', + token: ['tokens.spacingVerticalXXL'], + }), + ]) + ); + }); +}); diff --git a/packages/token-analyzer/src/__tests__/e2e.test.ts b/packages/token-analyzer/src/__tests__/e2e.test.ts new file mode 100644 index 000000000..857a4bf0e --- /dev/null +++ b/packages/token-analyzer/src/__tests__/e2e.test.ts @@ -0,0 +1,142 @@ +import * as path from 'path'; +import * as fs from 'fs/promises'; +import { analyzeProjectStyles } from '../index.js'; + +const styleFileName = 'useButtonStyles.styles.ts'; + +describe('e2e test', () => { + let tempDir: string; + let targetPath: string; + let analysis: any; + let styles: any; + + // generate our analysis file before all our tests run. Additionally, we set a long timeout + // to ensure that we have enough time to run the analysis. + beforeAll(async () => { + // Create temp directory for test files + tempDir = path.join(__dirname, 'test-files'); + await fs.mkdir(tempDir, { recursive: true }); + targetPath = path.join(__dirname, 'test-files', 'analysis.json'); + + await analyzeProjectStyles(tempDir, targetPath); + await fs.readFile(path.join(tempDir, 'analysis.json'), 'utf-8').then((analysisData) => { + // Parse the JSON data from our analysis and start validating it + analysis = JSON.parse(analysisData); + }); + + styles = analysis[styleFileName].styles; + }, 100000); + + afterAll(async () => { + // Clean up temp files + // await fs.rm(targetPath, { recursive: true, force: true }); + }); + + test('validate basic structure', () => { + // Validate the structure of the analysis object + expect(analysis).toHaveProperty([styleFileName]); + + // Validate that we process a makeResetStyles function useRootBaseClassName + expect(styles).toHaveProperty('useRootBaseClassName'); + + expect(analysis[styleFileName]).toHaveProperty('metadata'); + }); + + /** + * Factory function that outputs a function we can test against. This defines the token test params + * that we will reuse across our tests. Note that this function must be called within the test.each() function + * to ensure that the test context is properly set up. + * @param tokenArray an array of tokens to pass into our factory + * @returns a function we will call within test.each() + */ + const tokenTestFactory = (tokenArray: any) => { + return (propertyName: string, expectedToken: string) => { + const token = tokenArray.some((t: any) => t.property === propertyName && t.token.includes(expectedToken)); + expect(token).toBeTruthy(); + }; + }; + + /** + * Reusable function to check tokens vs a known set + * @param tokenArray the token array to test. Must be in the form of a function due to the lifecycle of Jest + * @param testArray the known set of tokens are we looking for + */ + const checkTokens = (tokenArray: () => any[], testArray: any[]) => { + test.each(testArray)('%s token is properly configured', (propertyName, expectedToken) => { + tokenTestFactory(tokenArray())(propertyName, expectedToken); + }); + + // Check if the length of the token array matches the expected length + test(`token array length should be ${testArray.length}`, () => { + expect(tokenArray().length).toBe(testArray.length); + }); + }; + + describe('validate makeResetStyles tokens', () => { + // Define token cases for hover makeResetStyles tests + checkTokens( + () => styles.useRootBaseClassName.resetStyles.nested["':hover'"].tokens, + [ + ['backgroundColor', 'cornerCtrlLgHoverRaw'], + ['borderColor', 'ctrlLinkForegroundBrandHover'], + ['color', 'tokens.colorNeutralForeground1Hover'], + ] + ); + + // Define token cases for active hover makeResetStyles tests + checkTokens( + () => styles.useRootBaseClassName.resetStyles.nested["':hover:active'"].tokens, + [ + ['backgroundColor', 'tokens.colorNeutralBackground1Pressed'], + ['borderColor', 'tokens.colorNeutralStroke1Pressed'], + ['color', 'tokens.colorNeutralForeground1Pressed'], + ] + ); + + // base makeResetStyles tests + checkTokens( + () => styles.useRootBaseClassName.resetStyles.tokens, + [ + ['backgroundColor', 'tokens.colorNeutralBackground1'], + ['color', 'semanticTokens.cornerFlyoutRest'], + ['border', 'tokens.strokeWidthThin'], + ['border', 'tokens.colorNeutralStroke1'], + ['fontFamily', 'textStyleAiHeaderFontfamily'], + ['padding', 'tokens.spacingHorizontalM'], + ['borderRadius', 'tokens.borderRadiusMedium'], + ['fontSize', 'tokens.fontSizeBase300'], + ['fontWeight', 'tokens.fontWeightSemibold'], + ['lineHeight', 'tokens.lineHeightBase300'], + ['transitionDuration', 'tokens.durationFaster'], + ['transitionTimingFunction', 'tokens.curveEasyEase'], + ] + ); + + // Token cases for makeResetStyles focus + checkTokens( + () => styles.useRootBaseClassName.resetStyles.nested[':focus'].tokens, + [ + ['borderColor', 'tokens.colorStrokeFocus2'], + ['borderRadius', 'tokens.borderRadiusMedium'], + ['outline', 'tokens.strokeWidthThick'], + ['outline', 'tokens.strokeWidthThick'], + ['boxShadow', 'tokens.strokeWidthThin'], + ['boxShadow', 'tokens.colorStrokeFocus2'], + ] + ); + + // Token cases for makeResetStyles mozilla bug + checkTokens( + () => + styles.useRootBaseClassName.resetStyles.nested["'@supports (-moz-appearance:button)'"].nested[':focus'].tokens, + [ + ['boxShadow', 'tokens.colorStrokeFocus2'], + ['boxShadow', 'tokens.strokeWidthThin'], + ] + ); + }); + + describe('validate makeStyles tokens', () => { + checkTokens(() => styles.useRootStyles.outline.tokens, [['backgroundColor', 'tokens.colorTransparentBackground']]); + }); +}); diff --git a/packages/token-analyzer/src/__tests__/moduleResolver.test.ts b/packages/token-analyzer/src/__tests__/moduleResolver.test.ts new file mode 100644 index 000000000..b1b6d9063 --- /dev/null +++ b/packages/token-analyzer/src/__tests__/moduleResolver.test.ts @@ -0,0 +1,203 @@ +// 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'; +import { findTsConfigPath } from '../findTsConfigPath'; + +// 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({ + tsConfigFilePath: findTsConfigPath() || '', + }); + + // 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/token-analyzer/src/__tests__/packageImports.test.ts b/packages/token-analyzer/src/__tests__/packageImports.test.ts new file mode 100644 index 000000000..682054550 --- /dev/null +++ b/packages/token-analyzer/src/__tests__/packageImports.test.ts @@ -0,0 +1,185 @@ +// 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'; +import { findTsConfigPath } from '../findTsConfigPath'; + +// 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({ + tsConfigFilePath: findTsConfigPath() || '', + }); + + // 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/token-analyzer/src/__tests__/processTemplateStringLiteral.test.ts b/packages/token-analyzer/src/__tests__/processTemplateStringLiteral.test.ts new file mode 100644 index 000000000..555fd80b2 --- /dev/null +++ b/packages/token-analyzer/src/__tests__/processTemplateStringLiteral.test.ts @@ -0,0 +1,139 @@ +// extractNodesFromTemplateStringLiteral.test.ts +import { Project, TemplateExpression } from 'ts-morph'; +import path from 'path'; +import { extractNodesFromTemplateStringLiteral } from '../processTemplateStringLiteral.js'; + +describe('extractNodesFromTemplateStringLiteral', () => { + // Set up the ts-morph project and load our test file + const project = new Project(); + const testFilePath = path.resolve(__dirname, './test-templates.ts'); + const sourceFile = project.addSourceFileAtPath(testFilePath); + + // Helper function to find a template expression by its variable name + const findTemplateByName = (name: string): TemplateExpression => { + const variableDeclarations = sourceFile.getVariableDeclarations().filter((vd) => vd.getName() === name); + + if (variableDeclarations.length !== 1) { + throw new Error(`Expected to find exactly one variable declaration with name ${name}`); + } + + const initializer = variableDeclarations[0].getInitializer(); + if (!initializer || !initializer.getKind() || !initializer.getKindName().includes('Template')) { + throw new Error(`Variable ${name} is not initialized to a template expression`); + } + + return initializer as TemplateExpression; + }; + + test('Test Case 1: Basic example with nested var() functions', () => { + const template = findTemplateByName('template1'); + const result = extractNodesFromTemplateStringLiteral(template); + + expect(result.extractedExpressions.length).toBe(2); + expect(result.extractedExpressions[0].length).toBe(2); + expect(result.extractedExpressions[1].length).toBe(2); + + // Verify the nodes are the correct ones by checking their text + expect(result.extractedExpressions[0][0].getText()).toBe('someNode'); + expect(result.extractedExpressions[0][1].getText()).toBe('anotherNode'); + expect(result.extractedExpressions[1][0].getText()).toBe('moreNodes'); + expect(result.extractedExpressions[1][1].getText()).toBe('evenMoreNodes'); + }); + + test('Test Case 2: Mixed content with different nesting patterns', () => { + const template = findTemplateByName('template2'); + const result = extractNodesFromTemplateStringLiteral(template); + + expect(result.extractedExpressions.length).toBe(2); + expect(result.extractedExpressions[0].length).toBe(1); + expect(result.extractedExpressions[1].length).toBe(3); + + expect(result.extractedExpressions[0][0].getText()).toBe('someNode'); + expect(result.extractedExpressions[1][0].getText()).toBe('moreNodes'); + expect(result.extractedExpressions[1][1].getText()).toBe('anotherNode'); + expect(result.extractedExpressions[1][2].getText()).toBe('evenMoreNodes'); + }); + + test('Test Case 3: No var functions - each expression gets its own group', () => { + const template = findTemplateByName('template3'); + const result = extractNodesFromTemplateStringLiteral(template); + + // Should extract both expressions, each in its own group + expect(result.extractedExpressions.length).toBe(2); + expect(result.extractedExpressions[0].length).toBe(1); + expect(result.extractedExpressions[1].length).toBe(1); + + expect(result.extractedExpressions[0][0].getText()).toBe('someNode'); + expect(result.extractedExpressions[1][0].getText()).toBe('anotherNode'); + }); + + test('Test Case 4: Simple case with one var function', () => { + const template = findTemplateByName('template4'); + const result = extractNodesFromTemplateStringLiteral(template); + + expect(result.extractedExpressions.length).toBe(1); + expect(result.extractedExpressions[0].length).toBe(1); + expect(result.extractedExpressions[0][0].getText()).toBe('someNode'); + }); + + test('Test Case 5: Deeply nested var() functions', () => { + const template = findTemplateByName('template5'); + const result = extractNodesFromTemplateStringLiteral(template); + + expect(result.extractedExpressions.length).toBe(1); + expect(result.extractedExpressions[0].length).toBe(3); + expect(result.extractedExpressions[0][0].getText()).toBe('someNode'); + expect(result.extractedExpressions[0][1].getText()).toBe('anotherNode'); + expect(result.extractedExpressions[0][2].getText()).toBe('moreNodes'); + }); + + test('Test Case 6: Multiple var() functions at the same level', () => { + const template = findTemplateByName('template6'); + const result = extractNodesFromTemplateStringLiteral(template); + + expect(result.extractedExpressions.length).toBe(4); + expect(result.extractedExpressions[0].length).toBe(1); + expect(result.extractedExpressions[1].length).toBe(1); + expect(result.extractedExpressions[2].length).toBe(1); + expect(result.extractedExpressions[3].length).toBe(1); + + expect(result.extractedExpressions[0][0].getText()).toBe('someNode'); + expect(result.extractedExpressions[1][0].getText()).toBe('anotherNode'); + expect(result.extractedExpressions[2][0].getText()).toBe('moreNodes'); + expect(result.extractedExpressions[3][0].getText()).toBe('evenMoreNodes'); + }); + + test('Test Case 7: Missing closing parentheses (edge case)', () => { + const template = findTemplateByName('template7'); + const result = extractNodesFromTemplateStringLiteral(template); + + // With missing closing parenthesis, all nodes stay in the same group + expect(result.extractedExpressions.length).toBe(1); + expect(result.extractedExpressions[0].length).toBe(3); + + expect(result.extractedExpressions[0][0].getText()).toBe('someNode'); + expect(result.extractedExpressions[0][1].getText()).toBe('anotherNode'); + expect(result.extractedExpressions[0][2].getText()).toBe('moreNodes'); + }); + + test('Test Case 8: Empty var() functions', () => { + const template = findTemplateByName('template8'); + const result = extractNodesFromTemplateStringLiteral(template); + + expect(result.extractedExpressions.length).toBe(1); + expect(result.extractedExpressions[0].length).toBe(1); + expect(result.extractedExpressions[0][0].getText()).toBe('someNode'); + }); + + test('Test Case 9: Mix of CSS properties and var() functions', () => { + const template = findTemplateByName('template9'); + const result = extractNodesFromTemplateStringLiteral(template); + + expect(result.extractedExpressions.length).toBe(2); + expect(result.extractedExpressions[0].length).toBe(1); + expect(result.extractedExpressions[1].length).toBe(1); + + expect(result.extractedExpressions[0][0].getText()).toBe('someNode'); + expect(result.extractedExpressions[1][0].getText()).toBe('anotherNode'); + }); +}); diff --git a/packages/token-analyzer/src/__tests__/reexportTracking.test.ts b/packages/token-analyzer/src/__tests__/reexportTracking.test.ts new file mode 100644 index 000000000..1d01fb616 --- /dev/null +++ b/packages/token-analyzer/src/__tests__/reexportTracking.test.ts @@ -0,0 +1,184 @@ +// reexportTracking.test.ts +import { Project } from 'ts-morph'; +import { analyzeImports, ImportedValue } from '../importAnalyzer'; +import * as path from 'path'; +import * as fs from 'fs'; +import { findTsConfigPath } from '../findTsConfigPath'; + +// 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 './defaults'; + + 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'), + ` + import { tokens } from '@fluentui/react-theme'; + + // 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.colorNeutralForeground1Hover; + + // Re-export default + export { default } from './defaults'; + ` + ); + + // Create a components file + fs.writeFileSync( + path.join(TEST_DIR, 'components.ts'), + ` + import { ctrlLinkForegroundBrandHover } from '@fluentui/semantic-tokens'; + export const Component = ctrlLinkForegroundBrandHover; + ` + ); + + // Create a values file + fs.writeFileSync( + path.join(TEST_DIR, 'values.ts'), + ` + import { tokens } from '@fluentui/react-theme'; + export const Value = tokens.borderRadiusCircular; + ` + ); + + // Create a utils file + fs.writeFileSync( + path.join(TEST_DIR, 'utils.ts'), + ` + import { tokens } from '@fluentui/react-theme'; + export const Utils = tokens.colorNeutralBackground1; + ` + ); + + // Create a defaults file + fs.writeFileSync( + path.join(TEST_DIR, 'defaults.ts'), + ` + import { tokens } from '@fluentui/react-theme'; + const DefaultValue = tokens.colorNeutralStroke1; + export default tokens.colorNeutralStroke1; + ` + ); +}); + +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: findTsConfigPath() || '', + 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('ctrlLinkForegroundBrandHover'); + 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.borderRadiusCircular'); + 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.colorNeutralBackground1'); + 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.colorNeutralForeground1Hover'); + 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.colorNeutralStroke1'); + expect(importedValues.get('DefaultExport')?.sourceFile).toContain('defaults.ts'); + }); +}); diff --git a/packages/token-analyzer/src/__tests__/sample-styles.ts b/packages/token-analyzer/src/__tests__/sample-styles.ts new file mode 100644 index 000000000..8deebab2f --- /dev/null +++ b/packages/token-analyzer/src/__tests__/sample-styles.ts @@ -0,0 +1,53 @@ +export const sampleStyles = ` +import { makeStyles, mergeClasses } from '@griffel/react'; +import { tokens } from '@fluentui/react-theme'; +import { createCustomFocusIndicatorStyle } from '@fluentui/react-tabster'; + +const useStyles = makeStyles({ + focusIndicator: createCustomFocusIndicatorStyle({ + textDecorationColor: tokens.colorStrokeFocus2, + }), + root: { + color: tokens.colorNeutralForeground1, + backgroundColor: tokens.colorNeutralBackground1, + ...shorthands.borderColor(tokens.colorNeutralStrokeDisabled), + ':hover': { + color: tokens.colorNeutralForegroundHover, + } + }, + large: { + fontSize: tokens.fontSizeBase600, + }, + disabled: { + color: tokens.colorNeutralForegroundDisabled, + }, + anotherSlot: { + color: tokens.colorNeutralForeground2, + } +}); + +export const Component = () => { + const styles = useStyles(); + + const state = {root:{}, anotherSlot: {}} + + state.root.className = mergeClasses( + styles.root, + styles.focusIndicator, + size === 'large' && styles.large, + disabled && styles.disabled, + state.root.className + ); + + state.anotherSlot.className = mergeClasses( + styles.anotherSlot, + state.anotherSlot.className + ); + + return ( +
+
+
+ ); +}; +`; diff --git a/packages/token-analyzer/src/__tests__/test-files/import-test.ts b/packages/token-analyzer/src/__tests__/test-files/import-test.ts new file mode 100644 index 000000000..9f1ae5038 --- /dev/null +++ b/packages/token-analyzer/src/__tests__/test-files/import-test.ts @@ -0,0 +1,4 @@ +import { ctrlLinkForegroundBrandHover } from '@fluentui/semantic-tokens'; +export { cornerCtrlLgHoverRaw } from './more-import-test'; + +export const importTest = ctrlLinkForegroundBrandHover; diff --git a/packages/token-analyzer/src/__tests__/test-files/more-import-test.ts b/packages/token-analyzer/src/__tests__/test-files/more-import-test.ts new file mode 100644 index 000000000..d20b3786a --- /dev/null +++ b/packages/token-analyzer/src/__tests__/test-files/more-import-test.ts @@ -0,0 +1,2 @@ +// test direct export from the semantic tokens package +export { cornerCtrlLgHoverRaw } from '@fluentui/semantic-tokens'; diff --git a/packages/token-analyzer/src/__tests__/test-files/useButtonStyles.styles.ts b/packages/token-analyzer/src/__tests__/test-files/useButtonStyles.styles.ts new file mode 100644 index 000000000..d334a0bc5 --- /dev/null +++ b/packages/token-analyzer/src/__tests__/test-files/useButtonStyles.styles.ts @@ -0,0 +1,607 @@ +import { iconFilledClassName, iconRegularClassName } from '@fluentui/react-icons'; +import { createCustomFocusIndicatorStyle } from '@fluentui/react-tabster'; +import { tokens } from '@fluentui/react-theme'; +import { shorthands, makeStyles, makeResetStyles, mergeClasses } from '@griffel/react'; +import type { SlotClassNames } from '@fluentui/react-utilities'; +import type { ButtonSlots, ButtonState } from '@fluentui/react-components'; +import * as semanticTokens from '@fluentui/semantic-tokens'; +import { textStyleAiHeaderFontfamily } from '@fluentui/semantic-tokens'; +import { importTest, cornerCtrlLgHoverRaw } from './import-test'; + +export const buttonClassNames: SlotClassNames = { + root: 'fui-Button', + icon: 'fui-Button__icon', +}; + +const iconSpacingVar = '--fui-Button__icon--spacing'; + +const tokenInInitializer = tokens.borderRadiusCircular; +const tokenInInitializer2 = tokens.colorNeutralBackground1; + +const buttonSpacingSmall = '3px'; +const buttonSpacingSmallWithIcon = '1px'; +const buttonSpacingMedium = '5px'; +const buttonSpacingLarge = '8px'; +const buttonSpacingLargeWithIcon = '7px'; + +/* Firefox has box shadow sizing issue at some zoom levels + * this will ensure the inset boxShadow is always uniform + * without affecting other browser platforms + */ +const boxShadowStrokeWidthThinMoz = `calc(${tokens.strokeWidthThin} + 0.25px)`; + +const useRootBaseClassName = makeResetStyles({ + alignItems: 'center', + boxSizing: 'border-box', + display: 'inline-flex', + justifyContent: 'center', + textDecorationLine: 'none', + verticalAlign: 'middle', + + margin: 0, + overflow: 'hidden', + + backgroundColor: tokenInInitializer2, + color: semanticTokens.cornerFlyoutRest, + border: `${tokens.strokeWidthThin} solid ${tokens.colorNeutralStroke1}`, + + fontFamily: textStyleAiHeaderFontfamily, + outlineStyle: 'none', + + ':hover': { + backgroundColor: cornerCtrlLgHoverRaw, + borderColor: importTest, + color: tokens.colorNeutralForeground1Hover, + + cursor: 'pointer', + }, + + ':hover:active': { + backgroundColor: tokens.colorNeutralBackground1Pressed, + borderColor: tokens.colorNeutralStroke1Pressed, + color: tokens.colorNeutralForeground1Pressed, + + outlineStyle: 'none', + }, + + padding: `${buttonSpacingMedium} ${tokens.spacingHorizontalM}`, + minWidth: '96px', + borderRadius: tokens.borderRadiusMedium, + + fontSize: tokens.fontSizeBase300, + fontWeight: tokens.fontWeightSemibold, + lineHeight: tokens.lineHeightBase300, + + // Transition styles + + transitionDuration: tokens.durationFaster, + transitionProperty: 'background, border, color', + transitionTimingFunction: tokens.curveEasyEase, + + '@media screen and (prefers-reduced-motion: reduce)': { + transitionDuration: '0.01ms', + }, + + // High contrast styles + + '@media (forced-colors: active)': { + ':focus': { + borderColor: 'ButtonText', + }, + + ':hover': { + backgroundColor: 'HighlightText', + borderColor: 'Highlight', + color: 'Highlight', + forcedColorAdjust: 'none', + }, + + ':hover:active': { + backgroundColor: 'HighlightText', + borderColor: 'Highlight', + color: 'Highlight', + forcedColorAdjust: 'none', + }, + }, + + // Focus styles + + ...createCustomFocusIndicatorStyle({ + borderColor: tokens.colorStrokeFocus2, + borderRadius: tokens.borderRadiusMedium, + borderWidth: '1px', + outline: `${tokens.strokeWidthThick} solid ${tokens.colorTransparentStroke}`, + boxShadow: `0 0 0 ${tokens.strokeWidthThin} ${tokens.colorStrokeFocus2} + inset + `, + zIndex: 1, + }), + + // BUGFIX: Mozilla specific styles (Mozilla BugID: 1857642) + '@supports (-moz-appearance:button)': { + ...createCustomFocusIndicatorStyle({ + boxShadow: `0 0 0 ${boxShadowStrokeWidthThinMoz} ${tokens.colorStrokeFocus2} + inset + `, + }), + }, +}); + +const useIconBaseClassName = makeResetStyles({ + alignItems: 'center', + display: 'inline-flex', + justifyContent: 'center', + + fontSize: '20px', + height: '20px', + width: '20px', + + [iconSpacingVar]: tokens.spacingHorizontalSNudge, +}); + +const useRootStyles = makeStyles({ + // Appearance variations + outline: { + backgroundColor: tokens.colorTransparentBackground, + + ':hover': { + backgroundColor: tokens.colorTransparentBackgroundHover, + }, + + ':hover:active': { + backgroundColor: tokens.colorTransparentBackgroundPressed, + }, + }, + primary: { + backgroundColor: tokens.colorBrandBackground, + ...shorthands.borderColor('transparent'), + color: tokens.colorNeutralForegroundOnBrand, + + ':hover': { + backgroundColor: tokens.colorBrandBackgroundHover, + ...shorthands.borderColor('transparent'), + color: tokens.colorNeutralForegroundOnBrand, + }, + + ':hover:active': { + backgroundColor: tokens.colorBrandBackgroundPressed, + ...shorthands.borderColor('transparent'), + color: tokens.colorNeutralForegroundOnBrand, + }, + + '@media (forced-colors: active)': { + backgroundColor: 'Highlight', + ...shorthands.borderColor('HighlightText'), + color: 'HighlightText', + forcedColorAdjust: 'none', + + ':hover': { + backgroundColor: 'HighlightText', + ...shorthands.borderColor('Highlight'), + color: 'Highlight', + }, + + ':hover:active': { + backgroundColor: 'HighlightText', + ...shorthands.borderColor('Highlight'), + color: 'Highlight', + }, + }, + }, + secondary: { + /* The secondary styles are exactly the same as the base styles. */ + }, + subtle: { + backgroundColor: tokens.colorSubtleBackground, + ...shorthands.borderColor('transparent'), + color: tokens.colorNeutralForeground2, + + ':hover': { + backgroundColor: tokens.colorSubtleBackgroundHover, + ...shorthands.borderColor('transparent'), + color: tokens.colorNeutralForeground2Hover, + [`& .${iconFilledClassName}`]: { + display: 'inline', + }, + [`& .${iconRegularClassName}`]: { + display: 'none', + }, + [`& .${buttonClassNames.icon}`]: { + color: tokens.colorNeutralForeground2BrandHover, + }, + }, + + ':hover:active': { + backgroundColor: tokens.colorSubtleBackgroundPressed, + ...shorthands.borderColor('transparent'), + color: tokens.colorNeutralForeground2Pressed, + [`& .${iconFilledClassName}`]: { + display: 'inline', + }, + [`& .${iconRegularClassName}`]: { + display: 'none', + }, + [`& .${buttonClassNames.icon}`]: { + color: tokens.colorNeutralForeground2BrandPressed, + }, + }, + + '@media (forced-colors: active)': { + ':hover': { + color: 'Highlight', + + [`& .${buttonClassNames.icon}`]: { + color: 'Highlight', + }, + }, + ':hover:active': { + color: 'Highlight', + + [`& .${buttonClassNames.icon}`]: { + color: 'Highlight', + }, + }, + }, + }, + transparent: { + backgroundColor: tokens.colorTransparentBackground, + ...shorthands.borderColor('transparent'), + color: tokens.colorNeutralForeground2, + + ':hover': { + backgroundColor: tokens.colorTransparentBackgroundHover, + ...shorthands.borderColor('transparent'), + color: tokens.colorNeutralForeground2BrandHover, + [`& .${iconFilledClassName}`]: { + display: 'inline', + }, + [`& .${iconRegularClassName}`]: { + display: 'none', + }, + }, + + ':hover:active': { + backgroundColor: tokens.colorTransparentBackgroundPressed, + ...shorthands.borderColor('transparent'), + color: tokens.colorNeutralForeground2BrandPressed, + [`& .${iconFilledClassName}`]: { + display: 'inline', + }, + [`& .${iconRegularClassName}`]: { + display: 'none', + }, + }, + + '@media (forced-colors: active)': { + ':hover': { + backgroundColor: tokens.colorTransparentBackground, + color: 'Highlight', + }, + ':hover:active': { + backgroundColor: tokens.colorTransparentBackground, + color: 'Highlight', + }, + }, + }, + + // Shape variations + circular: { borderRadius: tokens.borderRadiusCircular }, + rounded: { + /* The borderRadius rounded styles are handled in the size variations */ + }, + square: { borderRadius: tokens.borderRadiusNone }, + + // Size variations + small: { + minWidth: '64px', + padding: `${buttonSpacingSmall} ${tokens.spacingHorizontalS}`, + borderRadius: tokens.borderRadiusMedium, + + fontSize: tokens.fontSizeBase200, + fontWeight: tokens.fontWeightRegular, + lineHeight: tokens.lineHeightBase200, + }, + smallWithIcon: { + paddingBottom: buttonSpacingSmallWithIcon, + paddingTop: buttonSpacingSmallWithIcon, + }, + medium: { + /* defined in base styles */ + }, + large: { + minWidth: '96px', + padding: `${buttonSpacingLarge} ${tokens.spacingHorizontalL}`, + borderRadius: tokens.borderRadiusMedium, + + fontSize: tokens.fontSizeBase400, + fontWeight: tokens.fontWeightSemibold, + lineHeight: tokens.lineHeightBase400, + }, + largeWithIcon: { + paddingBottom: buttonSpacingLargeWithIcon, + paddingTop: buttonSpacingLargeWithIcon, + }, +}); + +const useRootDisabledStyles = makeStyles({ + // Base styles + base: { + backgroundColor: tokens.colorNeutralBackgroundDisabled, + ...shorthands.borderColor(tokens.colorNeutralStrokeDisabled), + color: tokens.colorNeutralForegroundDisabled, + + cursor: 'not-allowed', + [`& .${buttonClassNames.icon}`]: { + color: tokens.colorNeutralForegroundDisabled, + }, + + ':hover': { + backgroundColor: tokens.colorNeutralBackgroundDisabled, + ...shorthands.borderColor(tokens.colorNeutralStrokeDisabled), + color: tokens.colorNeutralForegroundDisabled, + + cursor: 'not-allowed', + + [`& .${iconFilledClassName}`]: { + display: 'none', + }, + [`& .${iconRegularClassName}`]: { + display: 'inline', + }, + [`& .${buttonClassNames.icon}`]: { + color: tokens.colorNeutralForegroundDisabled, + }, + }, + + ':hover:active': { + backgroundColor: tokens.colorNeutralBackgroundDisabled, + ...shorthands.borderColor(tokens.colorNeutralStrokeDisabled), + color: tokens.colorNeutralForegroundDisabled, + + cursor: 'not-allowed', + + [`& .${iconFilledClassName}`]: { + display: 'none', + }, + [`& .${iconRegularClassName}`]: { + display: 'inline', + }, + [`& .${buttonClassNames.icon}`]: { + color: tokens.colorNeutralForegroundDisabled, + }, + }, + }, + + // High contrast styles + highContrast: { + '@media (forced-colors: active)': { + backgroundColor: 'ButtonFace', + ...shorthands.borderColor('GrayText'), + color: 'GrayText', + + ':focus': { + ...shorthands.borderColor('GrayText'), + }, + + ':hover': { + backgroundColor: 'ButtonFace', + ...shorthands.borderColor('GrayText'), + color: 'GrayText', + }, + + ':hover:active': { + backgroundColor: 'ButtonFace', + ...shorthands.borderColor('GrayText'), + color: 'GrayText', + }, + }, + }, + + // Appearance variations + outline: { + backgroundColor: tokens.colorTransparentBackground, + + ':hover': { + backgroundColor: tokens.colorTransparentBackground, + }, + + ':hover:active': { + backgroundColor: tokens.colorTransparentBackground, + }, + }, + primary: { + ...shorthands.borderColor('transparent'), + + ':hover': { + ...shorthands.borderColor('transparent'), + }, + + ':hover:active': { + ...shorthands.borderColor('transparent'), + }, + }, + secondary: { + /* The secondary styles are exactly the same as the base styles. */ + }, + subtle: { + backgroundColor: tokens.colorTransparentBackground, + ...shorthands.borderColor('transparent'), + + ':hover': { + backgroundColor: tokens.colorTransparentBackground, + ...shorthands.borderColor('transparent'), + }, + + ':hover:active': { + backgroundColor: tokens.colorTransparentBackground, + ...shorthands.borderColor('transparent'), + }, + }, + transparent: { + backgroundColor: tokens.colorTransparentBackground, + ...shorthands.borderColor('transparent'), + + ':hover': { + backgroundColor: tokens.colorTransparentBackground, + ...shorthands.borderColor('transparent'), + }, + + ':hover:active': { + backgroundColor: tokens.colorTransparentBackground, + ...shorthands.borderColor('transparent'), + }, + }, +}); + +const useRootFocusStyles = makeStyles({ + // Shape variations + circular: createCustomFocusIndicatorStyle({ + borderRadius: tokens.borderRadiusCircular, + }), + rounded: { + /* The rounded styles are exactly the same as the base styles. */ + }, + square: createCustomFocusIndicatorStyle({ + borderRadius: tokens.borderRadiusNone, + }), + + // Primary styles + primary: { + ...createCustomFocusIndicatorStyle({ + // added another color here to test the shorthands output. + ...shorthands.borderColor(tokens.colorStrokeFocus2, tokens.colorStrokeFocus1, tokenInInitializer), + boxShadow: `${tokens.shadow2}, 0 0 0 ${tokens.strokeWidthThin} ${tokens.colorStrokeFocus2} inset, 0 0 0 ${tokens.strokeWidthThick} ${tokens.colorNeutralForegroundOnBrand} inset`, + ':hover': { + boxShadow: `${tokens.shadow2}, 0 0 0 ${tokens.strokeWidthThin} ${tokens.colorStrokeFocus2} inset`, + ...shorthands.borderColor(tokens.colorStrokeFocus2), + }, + }), + + // BUGFIX: Mozilla specific styles (Mozilla BugID: 1857642) + '@supports (-moz-appearance:button)': { + ...createCustomFocusIndicatorStyle({ + boxShadow: `${tokens.shadow2}, 0 0 0 ${boxShadowStrokeWidthThinMoz} ${tokens.colorStrokeFocus2} inset, 0 0 0 ${tokens.strokeWidthThick} ${tokens.colorNeutralForegroundOnBrand} inset`, + ':hover': { + boxShadow: `${tokens.shadow2}, 0 0 0 ${boxShadowStrokeWidthThinMoz} ${tokens.colorStrokeFocus2} inset`, + }, + }), + }, + }, + + // Size variations + small: createCustomFocusIndicatorStyle({ + borderRadius: tokens.borderRadiusSmall, + }), + medium: { + /* defined in base styles */ + }, + large: createCustomFocusIndicatorStyle({ + borderRadius: tokens.borderRadiusLarge, + }), +}); + +const useRootIconOnlyStyles = makeStyles({ + // Size variations + small: { + padding: buttonSpacingSmallWithIcon, + + minWidth: '24px', + maxWidth: '24px', + }, + medium: { + padding: buttonSpacingMedium, + + minWidth: '32px', + maxWidth: '32px', + }, + large: { + padding: buttonSpacingLargeWithIcon, + + minWidth: '40px', + maxWidth: '40px', + }, +}); + +const useIconStyles = makeStyles({ + // Size variations + small: { + fontSize: '20px', + height: '20px', + width: '20px', + + [iconSpacingVar]: tokens.spacingHorizontalXS, + }, + medium: { + /* defined in base styles */ + }, + large: { + fontSize: '24px', + height: '24px', + width: '24px', + + [iconSpacingVar]: tokens.spacingHorizontalSNudge, + }, + + // Icon position variations + before: { + marginRight: `var(${iconSpacingVar})`, + }, + after: { + marginLeft: `var(${iconSpacingVar})`, + }, +}); + +export const useButtonStyles_unstable = (state: ButtonState): ButtonState => { + 'use no memo'; + + const rootBaseClassName = useRootBaseClassName(); + const iconBaseClassName = useIconBaseClassName(); + + const rootStyles = useRootStyles(); + const rootDisabledStyles = useRootDisabledStyles(); + const rootFocusStyles = useRootFocusStyles(); + const rootIconOnlyStyles = useRootIconOnlyStyles(); + const iconStyles = useIconStyles(); + + const { appearance, disabled, disabledFocusable, icon, iconOnly, iconPosition, shape, size } = state; + + state.root.className = mergeClasses( + buttonClassNames.root, + rootBaseClassName, + + appearance && rootStyles[appearance], + + rootStyles[size], + icon && size === 'small' && rootStyles.smallWithIcon, + icon && size === 'large' && rootStyles.largeWithIcon, + rootStyles[shape], + + // Disabled styles + (disabled || disabledFocusable) && rootDisabledStyles.base, + (disabled || disabledFocusable) && rootDisabledStyles.highContrast, + appearance && (disabled || disabledFocusable) && rootDisabledStyles[appearance], + + // Focus styles + appearance === 'primary' && rootFocusStyles.primary, + rootFocusStyles[size], + rootFocusStyles[shape], + + // Icon-only styles + iconOnly && rootIconOnlyStyles[size], + + // User provided class name + state.root.className + ); + + if (state.icon) { + state.icon.className = mergeClasses( + buttonClassNames.icon, + iconBaseClassName, + !!state.root.children && iconStyles[iconPosition], + iconStyles[size], + state.icon.className + ); + } + + return state; +}; diff --git a/packages/token-analyzer/src/__tests__/test-templates.ts b/packages/token-analyzer/src/__tests__/test-templates.ts new file mode 100644 index 000000000..483d8a711 --- /dev/null +++ b/packages/token-analyzer/src/__tests__/test-templates.ts @@ -0,0 +1,31 @@ +export const someNode = 'some value'; +export const anotherNode = 'another value'; +export const moreNodes = 'more values'; +export const evenMoreNodes = 'even more values'; + +// Test case 1: Basic example with nested var() functions +export const template1 = `var(--a, var(${someNode}, var(${anotherNode}))) var(${moreNodes}, var(${evenMoreNodes}))`; + +// Test case 2: Mixed content with different nesting patterns +export const template2 = `var(--x) no-extraction var(--y, ${someNode}) var(${moreNodes}, var(--z, ${anotherNode}, ${evenMoreNodes}))`; + +// Test case 3: No var functions +export const template3 = `no var functions here just ${someNode} and ${anotherNode}`; + +// Test case 4: Simple case with one var function +export const template4 = `var(${someNode}) simple case`; + +// Test case 5: Deeply nested var() functions +export const template5 = `var(--deep, var(--deeper, var(--deepest, ${someNode}, ${anotherNode}, var(${moreNodes}))))`; + +// Test case 6: Multiple var() functions at the same level +export const template6 = `var(${someNode}) var(${anotherNode}) var(${moreNodes}) var(${evenMoreNodes})`; + +// Test case 7: Missing closing parentheses (edge case) +export const template7 = `var(--broken, var(${someNode}, var(${anotherNode})) var(${moreNodes})`; + +// Test case 8: Empty var() functions +export const template8 = `var(--empty) var(--also-empty, ${someNode})`; + +// Test case 9: Mix of CSS properties and var() functions +export const template9 = `color: red; background: var(--bg, ${someNode}); padding: 10px; border: var(--border, ${anotherNode})`; diff --git a/packages/token-analyzer/src/__tests__/typeCheckerImports.test.ts b/packages/token-analyzer/src/__tests__/typeCheckerImports.test.ts new file mode 100644 index 000000000..75c83de2d --- /dev/null +++ b/packages/token-analyzer/src/__tests__/typeCheckerImports.test.ts @@ -0,0 +1,159 @@ +// typeCheckerImports.test.ts +import { Project } from 'ts-morph'; +import { analyzeImports, ImportedValue } from '../importAnalyzer'; +import * as path from 'path'; +import * as fs from 'fs'; +import { findTsConfigPath } from '../findTsConfigPath'; + +// 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 './defaults'; + + 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'), + ` + import { tokens } from '@fluentui/react-theme'; + + // 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.colorNeutralForeground1Hover; + + // Re-export default + export { default } from './defaults'; + ` + ); + + // Create a components file + fs.writeFileSync( + path.join(TEST_DIR, 'components.ts'), + ` + import { ctrlLinkForegroundBrandHover } from '@fluentui/semantic-tokens'; + export const Component = ctrlLinkForegroundBrandHover; + ` + ); + + // Create a values file + fs.writeFileSync( + path.join(TEST_DIR, 'values.ts'), + ` + import { tokens } from '@fluentui/react-theme'; + export const Value = tokens.borderRadiusCircular; + ` + ); + + // Create a utils file + fs.writeFileSync( + path.join(TEST_DIR, 'utils.ts'), + ` + import { tokens } from '@fluentui/react-theme'; + export const Utils = tokens.colorNeutralBackground1; + ` + ); + + // Create a defaults file + fs.writeFileSync( + path.join(TEST_DIR, 'defaults.ts'), + ` + import { tokens } from '@fluentui/react-theme'; + const DefaultValue = tokens.colorNeutralStroke1; + export default tokens.colorNeutralStroke1; + ` + ); +}); + +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: findTsConfigPath() || '', + 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('ctrlLinkForegroundBrandHover'); + 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.borderRadiusCircular'); + 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.colorNeutralBackground1'); + 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.colorNeutralForeground1Hover'); + 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.colorNeutralStroke1'); + expect(importedValues.get('DefaultExport')?.sourceFile).toContain('defaults.ts'); + }); +}); diff --git a/packages/token-analyzer/src/__tests__/verifyFileExists.test.ts b/packages/token-analyzer/src/__tests__/verifyFileExists.test.ts new file mode 100644 index 000000000..9e48d7b65 --- /dev/null +++ b/packages/token-analyzer/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/token-analyzer/src/astAnalyzer.ts b/packages/token-analyzer/src/astAnalyzer.ts new file mode 100644 index 000000000..80ed6dd71 --- /dev/null +++ b/packages/token-analyzer/src/astAnalyzer.ts @@ -0,0 +1,354 @@ +import { Project, Node, SourceFile, PropertyAssignment, SpreadAssignment } from 'ts-morph'; +import { + TokenReference, + StyleAnalysis, + FileAnalysis, + StyleCondition, + StyleContent, + StyleMetadata, + StyleTokens, +} from './types.js'; +import { log, measure, measureAsync } from './debugUtils.js'; +import { analyzeImports, ImportedValue } from './importAnalyzer.js'; +import { resolveToken } from './tokenResolver'; + +const makeResetStylesToken = 'resetStyles'; + +interface StyleMapping { + baseStyles: string[]; + conditionalStyles: StyleCondition[]; + slotName?: string; +} + +interface VariableMapping { + variableName: string; + functionName: string; +} + +/** + * 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 | SpreadAssignment, + importedValues: Map, + project: Project, + isResetStyles?: boolean +): TokenReference[] { + let tokens: TokenReference[] = []; + const parentName = Node.isPropertyAssignment(prop) ? prop.getName() : ''; + + const path = isResetStyles && parentName ? [parentName] : []; + + // resolve all the tokens within our style recursively. This is encapsulated within the resolveToken function + tokens = resolveToken({ + node: Node.isPropertyAssignment(prop) ? prop.getInitializer() ?? prop : prop, + path, + parentName, + tokens, + importedValues, + project, + }); + + return tokens; +} + +/** + * Analyzes mergeClasses calls to determine style relationships + */ +function analyzeMergeClasses(sourceFile: SourceFile): StyleMapping[] { + const mappings: StyleMapping[] = []; + + sourceFile.forEachDescendant((node) => { + if (Node.isCallExpression(node) && node.getExpression().getText() === 'mergeClasses') { + const parentNode = node.getParent(); + let slotName = ''; + if (Node.isBinaryExpression(parentNode)) { + slotName = parentNode.getLeft().getText().split('.')[1]; + } + const mapping: StyleMapping = { + baseStyles: [], + conditionalStyles: [], + slotName, + }; + + /** + * TODO: We could also walk the tree to find what function is assigned to our makeStyles call, and thus, what + * styles object we're working with. Typically this is called `useStyles` and then assigned to `styles`. We've hard + * coded it for now but this could be improved. + */ + + node.getArguments().forEach((arg) => { + // Handle direct style references + if (Node.isPropertyAccessExpression(arg)) { + mapping.baseStyles.push(arg.getText()); + } + // Handle conditional styles + else if (Node.isBinaryExpression(arg)) { + const right = arg.getRight(); + if (Node.isPropertyAccessExpression(right)) { + mapping.conditionalStyles.push({ + style: right.getText(), + condition: arg.getLeft().getText(), + }); + } + } else if (!arg.getText().includes('.')) { + // We found a single variable (makeResetStyles or other assignment), add to base styles for lookup later + mapping.baseStyles.push(arg.getText()); + } + }); + + if (mapping.baseStyles.length || mapping.conditionalStyles.length) { + mappings.push(mapping); + } + } + }); + + return mappings; +} + +/** + * Creates a StyleContent object from token references. + * + * The path structure in token references is relative to the style property being processed. + * For example, given a style object: + * ```typescript + * { + * root: { // Handled by analyzeMakeStyles + * color: token, // path = ['color'] + * ':hover': { // Start of nested structure + * color: token // path = [':hover', 'color'] + * } + * } + * } + * ``` + * Property names reflect the actual CSS property, derived from the path. + */ +function createStyleContent(tokens: TokenReference[]): StyleContent { + const content: StyleContent = { + tokens: tokens.filter((t) => { + return t.path.length === 1; + }), + }; + + // Nested structures have paths longer than 1 + const nestedTokens = tokens.filter((t) => t.path.length > 1); + if (nestedTokens.length > 0) { + const acc: StyleTokens = {}; + + /** + * Recursive function to create a nested structure for tokens + * This function will create a nested object structure based on the token path. + * @param token + * @param pathIndex where in the path we are, this allows us to preserve the path while recursing through it + * @param currentLevel the current level of the nested structure we're working on + */ + const createNestedStructure = (token: TokenReference, pathIndex: number, currentLevel: StyleTokens) => { + const nestedKey = token.path[pathIndex]; + + // if no token array exists, create one + if (!currentLevel[nestedKey]) { + currentLevel[nestedKey] = { tokens: [] }; + } + + // if we have a path length that is greater than our current index minus 1, we need to recurse + // this is because if we have more than a single item in our path left there's another level + if (token.path.length - 1 - pathIndex > 1) { + // Create a nested structure through a recursive call + let cuurrentLevel = currentLevel[nestedKey].nested; + if (!cuurrentLevel) { + cuurrentLevel = {}; + } + currentLevel[nestedKey].nested = cuurrentLevel; + createNestedStructure(token, pathIndex + 1, cuurrentLevel); + } else { + currentLevel[nestedKey].tokens.push({ + ...token, + path: token.path, + }); + } + }; + + nestedTokens.forEach((token) => { + createNestedStructure(token, 0, acc); + }); + + content.nested = acc; + } + + return content; +} + +/** + * Creates metadata from style mappings + */ +function createMetadata(styleMappings: StyleMapping[]): StyleMetadata { + const metadata: StyleMetadata = { + styleConditions: {}, + }; + + styleMappings.forEach((mapping) => { + mapping.baseStyles.forEach((style) => { + if (metadata.styleConditions[style]) { + metadata.styleConditions[style].isBase = true; + } else { + metadata.styleConditions[style] = { + isBase: true, + slotName: mapping.slotName || '', + }; + } + }); + + mapping.conditionalStyles.forEach(({ style, condition }) => { + if (metadata.styleConditions[style]) { + metadata.styleConditions[style].conditions = metadata.styleConditions[style].conditions || []; + if (condition) { + metadata.styleConditions[style].conditions!.push(condition); + } + } else { + metadata.styleConditions[style] = { + conditions: condition ? [condition] : [], + slotName: mapping.slotName || '', + }; + } + }); + }); + + return metadata; +} + +/** + * Analyzes makeStyles calls to get token usage and structure + */ +async function analyzeMakeStyles( + sourceFile: SourceFile, + importedValues: Map, + project: Project +): Promise { + const analysis: StyleAnalysis = {}; + + sourceFile.forEachDescendant((node) => { + if (Node.isCallExpression(node) && node.getExpression().getText() === 'makeStyles') { + const stylesArg = node.getArguments()[0]; + const parentNode = node.getParent(); + if (Node.isObjectLiteralExpression(stylesArg) && Node.isVariableDeclaration(parentNode)) { + // Process the styles object + stylesArg.getProperties().forEach((prop) => { + if (Node.isPropertyAssignment(prop)) { + const styleName = prop.getName(); + const tokens = processStyleProperty(prop, importedValues, project); + const functionName = parentNode.getName(); + if (!analysis[functionName]) { + analysis[functionName] = {}; + } + if (tokens.length) { + analysis[functionName][styleName] = createStyleContent(tokens); + } + } + }); + } + } else if (Node.isCallExpression(node) && node.getExpression().getText() === 'makeResetStyles') { + // Similar to above, but the styles are stored under the assigned function name instead of local variable + const stylesArg = node.getArguments()[0]; + const parentNode = node.getParent(); + if (Node.isVariableDeclaration(parentNode)) { + const functionName = parentNode.getName(); + if (!analysis[functionName]) { + analysis[functionName] = {}; + } + // We store 'isResetStyles' to differentiate from makeStyles and link mergeClasses variables + analysis[functionName][makeResetStylesToken] = { + tokens: [], + nested: {}, + isResetStyles: true, + }; + if (Node.isObjectLiteralExpression(stylesArg)) { + // Process the styles object + stylesArg.getProperties().forEach((prop) => { + if (Node.isPropertyAssignment(prop) || Node.isSpreadAssignment(prop)) { + const tokens = processStyleProperty(prop, importedValues, project, true); + if (tokens.length) { + const styleContent = createStyleContent(tokens); + analysis[functionName][makeResetStylesToken].tokens = analysis[functionName][ + makeResetStylesToken + ].tokens.concat(styleContent.tokens); + analysis[functionName][makeResetStylesToken].nested = { + ...analysis[functionName][makeResetStylesToken].nested, + ...styleContent.nested, + }; + } + } + }); + } + } + } + }); + + const variables: VariableMapping[] = []; + const styleFunctionNames: string[] = Object.keys(analysis); + + sourceFile.forEachDescendant((node) => { + // We do a second parse to link known style functions (i.e. makeResetStyles assigned function variable names). + // This is necessary to handle cases where we're using a variable directly in mergeClasses to link styles. + + if (Node.isCallExpression(node) && styleFunctionNames.includes(node.getExpression().getText())) { + const parentNode = node.getParent(); + const functionName = node.getExpression().getText(); + if (Node.isVariableDeclaration(parentNode)) { + const variableName = parentNode.getName(); + const variableMap: VariableMapping = { + functionName, + variableName, + }; + variables.push(variableMap); + } + } + }); + + // Store our makeResetStyles assigned variables in the analysis to link later + variables.forEach((variable) => { + Object.keys(analysis[variable.functionName]).forEach((styleName) => { + if (analysis[variable.functionName][styleName].assignedVariables === undefined) { + analysis[variable.functionName][styleName].assignedVariables = []; + } + analysis[variable.functionName][styleName].assignedVariables?.push(variable.variableName); + }); + }); + + return 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 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)); + + // Third pass: Analyze makeStyles with imported values + const styleAnalysis = await measureAsync('analyze makeStyles', () => + analyzeMakeStyles(sourceFile, importedValues, project) + ); + + // Create enhanced analysis with separated styles and metadata + return { + styles: styleAnalysis, + metadata: createMetadata(styleMappings), + }; +} + +export { analyzeFile, processStyleProperty, analyzeMergeClasses, analyzeMakeStyles, createStyleContent }; +export type { StyleMapping }; diff --git a/packages/token-analyzer/src/debugUtils.ts b/packages/token-analyzer/src/debugUtils.ts new file mode 100644 index 000000000..6f492308e --- /dev/null +++ b/packages/token-analyzer/src/debugUtils.ts @@ -0,0 +1,56 @@ +import { performance } from 'perf_hooks'; + +interface DebugConfig { + debug: boolean; + perf: boolean; +} + +let config: DebugConfig = { + debug: false, + perf: false, +}; + +export const configure = (options: Partial): void => { + config = { ...config, ...options }; +}; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export const log = (message: string, ...args: any[]): void => { + if (config.debug) { + console.log(`DEBUG: ${message}`, ...args); + } +}; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export const error = (message: string, errorArg: any): void => { + // Always log errors, but with debug info if enabled + const prefix = config.debug ? 'DEBUG ERROR: ' : 'ERROR: '; + console.error(`${prefix}${message}`, errorArg); +}; + +export const measureAsync = async (name: string, fn: () => Promise): Promise => { + if (!config.perf) { + return fn(); + } + + const start = performance.now(); + return fn().finally(() => { + const end = performance.now(); + console.log(`PERF: ${name} took ${(end - start).toFixed(2)}ms`); + }); +}; + +export const measure = (name: string, fn: () => T): T => { + if (!config.perf) { + return fn(); + } + + const start = performance.now(); + const result = fn(); + const end = performance.now(); + console.log(`PERF: ${name} took ${(end - start).toFixed(2)}ms`); + return result; +}; + +// Re-export types that consumers might need +export type { DebugConfig }; diff --git a/packages/token-analyzer/src/fileOperations.ts b/packages/token-analyzer/src/fileOperations.ts new file mode 100644 index 000000000..419d871b6 --- /dev/null +++ b/packages/token-analyzer/src/fileOperations.ts @@ -0,0 +1,74 @@ +import { promises as fs } from 'fs'; +import { join, resolve, dirname } from 'path'; +import { IGNORED_DIRS, VALID_EXTENSIONS } from './types.js'; + +/** + * Recursively finds all style-related files in a directory + * @param dir The root directory to start searching from + * @returns Array of absolute file paths + */ +export async function findStyleFiles(dir: string): Promise { + const styleFiles: string[] = []; + + async function scan(directory: string): Promise { + const entries = await fs.readdir(directory, { withFileTypes: true }); + + const scanPromises = entries.map(async (entry) => { + const fullPath = join(directory, entry.name); + + if (entry.isDirectory() && !IGNORED_DIRS.includes(entry.name)) { + await scan(fullPath); + } else if ( + (entry.name.includes('style') || entry.name.includes('styles')) && + VALID_EXTENSIONS.some((ext) => entry.name.endsWith(ext)) + ) { + styleFiles.push(fullPath); + } + }); + + await Promise.all(scanPromises); + } + + await scan(dir); + return styleFiles; +} + +/** + * Resolves a relative import path to an absolute file path + * @param importPath The import path from the source file + * @param currentFilePath The path of the file containing the import + * @returns Resolved absolute path or null if not found + */ +export async function resolveImportPath(importPath: string, currentFilePath: string): Promise { + if (!importPath.startsWith('.')) { + return null; + } + + const dir = dirname(currentFilePath); + const absolutePath = resolve(dir, importPath); + + // Try original path + try { + const stats = await fs.stat(absolutePath); + if (stats.isFile()) { + return absolutePath; + } + } catch { + // Ignore errors and try extensions + } + + // Try with extensions + for (const ext of VALID_EXTENSIONS) { + const pathWithExt = absolutePath + ext; + try { + const stats = await fs.stat(pathWithExt); + if (stats.isFile()) { + return pathWithExt; + } + } catch { + // Ignore errors and continue trying + } + } + + return null; +} diff --git a/packages/token-analyzer/src/findTsConfigPath.ts b/packages/token-analyzer/src/findTsConfigPath.ts new file mode 100644 index 000000000..5fa32daeb --- /dev/null +++ b/packages/token-analyzer/src/findTsConfigPath.ts @@ -0,0 +1,28 @@ +import * as path from 'path'; +import * as fs from 'fs'; + +export function findTsConfigPath(startDir = __dirname): string | null { + let currentDir = startDir; + const root = path.parse(currentDir).root; + + while (currentDir !== root) { + const tsConfigPath = path.join(currentDir, 'tsconfig.json'); + if (fs.existsSync(tsConfigPath)) { + return tsConfigPath; + } + + // Check if we've hit the file system root dir and bail if we have and haven't found a tsconfig.json + // This prevents infinite loops in case of misconfigured paths + if (currentDir === path.dirname(currentDir)) { + console.warn(`Hit the root directory looking for tsconfig. Stopping search for tsconfig.json.`); + return null; + } else { + // Move up to parent directory + currentDir = path.dirname(currentDir); + } + } + + // Check root directory as well + const rootTsConfigPath = path.join(root, 'tsconfig.json'); + return fs.existsSync(rootTsConfigPath) ? rootTsConfigPath : null; +} diff --git a/packages/token-analyzer/src/importAnalyzer.ts b/packages/token-analyzer/src/importAnalyzer.ts new file mode 100644 index 000000000..dc21c7d77 --- /dev/null +++ b/packages/token-analyzer/src/importAnalyzer.ts @@ -0,0 +1,190 @@ +// importAnalyzer.ts +import { Project, Node, SourceFile, ImportDeclaration, TypeChecker, ts } from 'ts-morph'; +import { log } from './debugUtils.js'; +import { resolveExport, ExportInfo } from './reexportResolver'; +import { getModuleSourceFile } from './moduleResolver.js'; +import { isKnownTokenPackage } from './tokenUtils'; + +export interface TemplateGroupItem { + node: Node; + actualTokenValue?: string; +} + +/** + * Represents a value imported from another module + */ +export interface ImportedValue { + value: string; + sourceFile: string; + node: Node; + declaredValue?: string; + declarationNode?: Node; + templateGroups?: TemplateGroupItem[][]; +} + +// Context passed through each import handler for clearer signature +interface ImportContext { + importDecl: ImportDeclaration; + moduleSpecifier: string; + importedFile: SourceFile; + typeChecker: TypeChecker; + importedValues: Map; + project: Project; +} + +/** + * 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; + } + + const context: ImportContext = { + importDecl, + moduleSpecifier, + importedFile, + typeChecker, + importedValues, + project, + }; + + // Process named imports (import { x } from 'module') + processNamedImports(context); + + // Process default import (import x from 'module') + processDefaultImport(context); + + processNamespaceImport(context); +} + +/** + * Process named imports using TypeScript's type checker to follow re-exports + */ +function processNamedImports(context: ImportContext): void { + const { importDecl, typeChecker, project } = context; + + const namedImports = importDecl.getNamedImports(); + + namedImports.forEach((namedImport) => { + const nameOrAliasNode = namedImport.getAliasNode() ?? namedImport; + const importName = namedImport.getName(); + const alias = namedImport.getAliasNode()?.getText() || importName; + + if (isKnownTokenPackage(context.moduleSpecifier, importName)) { + // we have a direct token import, record it and move on. + recordImport(context, alias, nameOrAliasNode); + } else if (ts.isExternalModuleNameRelative(context.moduleSpecifier)) { + // We know it's not a direct token reference but it's a relative import, so it could contain + // token references and we need to do further processing. + + const exportInfo: ExportInfo | undefined = resolveExport(context.importedFile, importName, typeChecker, project); + + if (exportInfo) { + recordImport(context, alias, nameOrAliasNode, exportInfo); + // addTemplateGroups(context.importedValues.get(alias)!); + } + } + }); +} + +/** + * Process default import using TypeScript's type checker + */ +function processDefaultImport(context: ImportContext): void { + const { importDecl, typeChecker, project } = context; + + const defaultImport = importDecl.getDefaultImport(); + if (!defaultImport) { + log(`No default import found in ${importDecl.getModuleSpecifierValue()}`); + return; + } + + const importName = defaultImport.getText(); + if (isKnownTokenPackage(context.moduleSpecifier)) { + recordImport(context, importName, importDecl); + } else { + const exportInfo: ExportInfo | undefined = resolveExport(context.importedFile, 'default', typeChecker, project); + + if (exportInfo) { + recordImport(context, importName, defaultImport, exportInfo); + } + } +} + +function processNamespaceImport(context: ImportContext): void { + const { importDecl, moduleSpecifier, importedFile, importedValues } = context; + + const namespaceImport = importDecl.getNamespaceImport(); + if (!namespaceImport) { + log(`No namespace import found in ${importDecl.getModuleSpecifierValue()}`); + return; + } + + const importName = namespaceImport.getText(); + // Only record namespace import if it's the tokens package + if (isKnownTokenPackage(moduleSpecifier)) { + importedValues.set(importName, { + value: importName, + node: namespaceImport, + sourceFile: importedFile.getFilePath(), + }); + } +} + +// Helper to record an import consistently +function recordImport(ctx: ImportContext, alias: string, node: Node, exportInfo?: ExportInfo): void { + const { importedValues, importedFile } = ctx; + + // Only record known token imports + const source = exportInfo?.sourceFile ?? importedFile; + + // Use actual token literal when available + const importValue = exportInfo?.valueDeclarationValue ?? alias; + importedValues.set(alias, { + value: importValue, + node, + sourceFile: source.getFilePath(), + declaredValue: exportInfo?.valueDeclarationValue, + declarationNode: exportInfo?.declaration, + templateGroups: exportInfo?.templateGroups, + }); + log(`Recorded token import: ${alias} from ${source.getFilePath()}`); +} diff --git a/packages/token-analyzer/src/index.ts b/packages/token-analyzer/src/index.ts index cb0ff5c3b..e5360a6c3 100644 --- a/packages/token-analyzer/src/index.ts +++ b/packages/token-analyzer/src/index.ts @@ -1 +1,198 @@ -export {}; +#!/usr/bin/env node +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'; +import { configure, log, error, measureAsync } from './debugUtils.js'; +import { findTsConfigPath } from './findTsConfigPath.js'; +import { hideBin } from 'yargs/helpers'; +import yargs from 'yargs/yargs'; + +async function analyzeProjectStyles( + rootDir: string, + outputFile?: string, + options: { debug?: boolean; perf?: boolean } = {} +): Promise { + configure({ + debug: options.debug || false, + perf: options.perf || false, + }); + + log(`Starting analysis of ${rootDir}`); + const results: AnalysisResults = {}; + + try { + const styleFiles = await measureAsync('find style files', () => findStyleFiles(rootDir)); + console.log(`Found ${styleFiles.length} style files to analyze`); + + const project = new Project({ + // Get the nearest tsconfig.json file so we can resolve modules and paths correctly based on the project config + tsConfigFilePath: findTsConfigPath(rootDir) ?? undefined, + skipAddingFilesFromTsConfig: true, + skipFileDependencyResolution: false, + }); + + for (const file of styleFiles) { + const relativePath = relative(rootDir, file); + console.log(`Analyzing ${relativePath}...`); + + try { + const analysis = await analyzeFile(file, project); + if (Object.keys(analysis.styles).length > 0) { + results[relativePath] = { + styles: analysis.styles, + metadata: analysis.metadata, + }; + } + } catch (err) { + error(`Error analyzing ${relativePath}:`, err); + } + } + + if (outputFile) { + await measureAsync('write output file', async () => { + 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}`); + }); + } + + return results; + } catch (err) { + error('Error during analysis:', err); + throw err; + } +} + +/** + * 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) => { + Object.values(_value).forEach((value) => { + count += value.tokens.length; + if (value.nested) { + Object.values(value.nested).forEach((nestedValue) => { + count += nestedValue.tokens.length; + }); + } + }); + }); + return count; +} + +// Define the expected CLI arguments interface +interface CliArgs { + root: string; + output: string; + debug: boolean; + perf: boolean; +} + +// CLI execution +const isRunningDirectly = + require.main === module || // Standard Node.js detection + process.argv[1].includes('token-analyzer') || // When run as global CLI + process.argv[1].endsWith('index.js') || // When run directly + process.argv[1].includes('index'); // Your original check + +if (isRunningDirectly) { + const argv = yargs(hideBin(process.argv)) + .usage('$0 [options]', 'Analyze project styles and token usage') + .option('root', { + alias: 'r', + describe: 'Root directory to analyze', + type: 'string', + default: '.', + }) + .option('output', { + alias: 'o', + describe: 'Output file path', + type: 'string', + default: './token-analysis.json', + }) + .option('debug', { + alias: 'd', + describe: 'Enable debug mode', + type: 'boolean', + default: false, + }) + .option('perf', { + alias: 'p', + describe: 'Enable performance tracking', + type: 'boolean', + default: false, + }) + .example('$0', 'Run with default settings') + .example('$0 --root ./components --output ./results.json', 'Analyze components directory') + .example('$0 -r ./src -o ./analysis.json --debug', 'Run with debug mode') + .help('h') + .alias('h', 'help') + .version() + .strict() + .parseSync() as CliArgs; + + const { root: rootDir, output: outputFile, debug, perf } = argv; + + console.log(`Starting analysis of ${rootDir}`); + console.log(`Output will be written to ${outputFile}`); + + if (debug) console.log('Debug mode enabled'); + if (perf) console.log('Performance tracking enabled'); + + analyzeProjectStyles(rootDir, outputFile, { debug, perf }) + .then((results) => { + const totalFiles = Object.keys(results).length; + let totalTokens = 0; + + Object.values(results).forEach((fileAnalysis) => { + totalTokens += countTokens(fileAnalysis); + }); + + console.log('\nAnalysis complete!'); + console.log(`Processed ${totalFiles} files containing styles`); + console.log(`Found ${totalTokens} token references`); + }) + .catch((err) => { + console.error('Analysis failed:', err); + process.exit(1); + }); +} + +export { analyzeProjectStyles }; +export type { + AnalysisResults, + FileAnalysis, + KnownTokenImportsAndModules, + StyleAnalysis, + StyleCondition, + StyleContent, + StyleMetadata, + StyleTokens, + TokenMap, + TokenReference, + TokenResolverInfo, +} from './types'; diff --git a/packages/token-analyzer/src/moduleResolver.ts b/packages/token-analyzer/src/moduleResolver.ts new file mode 100644 index 000000000..540f7cdfe --- /dev/null +++ b/packages/token-analyzer/src/moduleResolver.ts @@ -0,0 +1,213 @@ +// 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 + log(`Error checking file existence with TypeScript: ${filePath}`, e); + try { + return fs.existsSync(filePath); + } catch (nestedE) { + log(`Error checking file existence: ${filePath}`, 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)) { + const cachedPath = modulePathCache.get(cacheKey); + if (cachedPath) { + return cachedPath; + } + } + + // 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 + log(`Error resolving module: ${moduleSpecifier}`, e); + } + } + + // 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)) { + const cachedFile = resolvedFilesCache.get(resolvedPath); + if (cachedFile) { + return cachedFile; + } + } + + // 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/token-analyzer/src/processTemplateStringLiteral.ts b/packages/token-analyzer/src/processTemplateStringLiteral.ts new file mode 100644 index 000000000..eb354b31f --- /dev/null +++ b/packages/token-analyzer/src/processTemplateStringLiteral.ts @@ -0,0 +1,101 @@ +import { TemplateExpression, Node } from 'ts-morph'; + +interface ExtractedNodessFromTemplateStringLiteral { + /** + * The original template expression that we're processing. + */ + originalExpression: TemplateExpression; + /** + * 3D array of nodes that are within var() functions. Each group of nodes is stored in a separate array. + */ + extractedExpressions: Node[][]; +} + +/** + * pulls nodes out of a template string literal in the order they appear in if they're within var() functions. + * If there are multiple non-nested var() functions, we place them in a 3d array at the top level. So grouped Nodes stay + * together and the order is maintained. + * ex: + * for string `var(--a, var(${someNode}, var(${anotherNode}))) var(${moreNodes}, var(${evenMoreNodes}))` + * we would return: + * [ + * [someNode, anotherNode], + * [moreNodes, evenMoreNodes] + * ] + * @param expression + */ +export const extractNodesFromTemplateStringLiteral = ( + expression: TemplateExpression +): ExtractedNodessFromTemplateStringLiteral => { + const extractedExpressions: Node[][] = []; + const spans = expression.getTemplateSpans(); + + // Track the state as we process each part of the template + let currentGroup: Node[] = []; + let inVar = false; + let nestingLevel = 0; + + // Process the template head + const head = expression.getHead().getText(); + processText(head); + + // Process each span and its literal + spans.forEach((span) => { + // Process the expression + const expr = span.getExpression(); + if (inVar) { + // If inside var(), add to current group + currentGroup.push(expr); + } else { + // If not inside var(), create a standalone group for this expression + extractedExpressions.push([expr]); + } + + // Process the literal text after this expression + const literal = span.getLiteral().getText(); + processText(literal); + }); + + // Helper function to process text parts + function processText(text: string) { + for (let i = 0; i < text.length; i++) { + // Check for start of var() - no whitespace allowed + if (i + 3 < text.length && text.substring(i, i + 4) === 'var(' && (nestingLevel === 0 || inVar)) { + if (nestingLevel === 0) { + inVar = true; + // If we already have a group, add it to our results + if (currentGroup.length > 0) { + extractedExpressions.push([...currentGroup]); + currentGroup = []; + } + } + nestingLevel++; + i += 3; // Skip to the opening parenthesis + } + // Track parenthesis nesting + else if (text[i] === '(' && inVar) { + nestingLevel++; + } else if (text[i] === ')' && inVar) { + nestingLevel--; + if (nestingLevel === 0) { + inVar = false; + // If we've closed a var() and have a group, add it + if (currentGroup.length > 0) { + extractedExpressions.push([...currentGroup]); + currentGroup = []; + } + } + } + } + } + + // Handle any remaining nodes in the current group + if (currentGroup.length > 0) { + extractedExpressions.push(currentGroup); + } + + return { + originalExpression: expression, + extractedExpressions, + }; +}; diff --git a/packages/token-analyzer/src/reexportResolver.ts b/packages/token-analyzer/src/reexportResolver.ts new file mode 100644 index 000000000..646d16e7a --- /dev/null +++ b/packages/token-analyzer/src/reexportResolver.ts @@ -0,0 +1,229 @@ +import { Node, SourceFile, TypeChecker, SyntaxKind, ts, Project, ImportSpecifier } from 'ts-morph'; +import { log } from './debugUtils.js'; +import { isKnownTokenPackage } from './tokenUtils'; +import { getModuleSourceFile } from './moduleResolver'; +import { extractNodesFromTemplateStringLiteral } from './processTemplateStringLiteral'; +import { TemplateGroupItem } from './importAnalyzer'; + +export interface ExportInfo { + declaration: Node; + sourceFile: SourceFile; + moduleSpecifier: string; + importExportSpecifierName?: string; + valueDeclarationValue?: string; + templateGroups?: TemplateGroupItem[][]; +} + +// Resolves an export name to its original declaration, following aliases and re-exports +export function resolveExport( + sourceFile: SourceFile, + exportName: string, + typeChecker: TypeChecker, + project: Project +): ExportInfo | undefined { + try { + // Get module symbol + const moduleSymbol = typeChecker.getSymbolAtLocation(sourceFile); + if (!moduleSymbol) { + return undefined; + } + + // Get all direct exports + const exportsArr = typeChecker.getExportsOfModule(moduleSymbol); + // Find direct export symbol + const symbol = exportsArr.find((s) => s.getName() === exportName); + if (symbol) { + const exportSpecifier = symbol?.getDeclarations().find(Node.isExportSpecifier); + const varDeclaration = symbol?.getDeclarations().find(Node.isVariableDeclaration); + const exportAssignment = symbol?.getDeclarations().find(Node.isExportAssignment); + + if (varDeclaration) { + // if we have a simple variable declaration that points to a known token import, we can return it, + // if we have a template expression, we need to process with extractNodesFromTemplateStringLiteral + // and then determine if any of the nodes are known token packages. + const initializer = varDeclaration.getInitializer(); + log(`getting the type of var declaration ${initializer?.getKindName()}`); + if (Node.isIdentifier(initializer)) { + const importSpecifier = initializer.getSymbol()?.getDeclarations().find(Node.isImportSpecifier); + if (importSpecifier) { + const specifierName = importSpecifier.getName(); + const importDeclaration = importSpecifier.getFirstAncestorByKind(SyntaxKind.ImportDeclaration); + const moduleSpecifier = importDeclaration?.getModuleSpecifierValue(); + if (moduleSpecifier !== undefined && isKnownTokenPackage(moduleSpecifier, specifierName)) { + // found a known token, process + return { + declaration: importSpecifier, + sourceFile: importSpecifier.getSourceFile(), + moduleSpecifier, + importExportSpecifierName: specifierName, + valueDeclarationValue: specifierName, + }; + } else if (moduleSpecifier !== undefined && ts.isExternalModuleNameRelative(moduleSpecifier)) { + const moduleSourceFile = getModuleSourceFile(project, moduleSpecifier, sourceFile.getFilePath()); + if (moduleSourceFile) { + return resolveExport(moduleSourceFile, specifierName, typeChecker, project); + } + } + } else { + // if we don't have an import specifier, we should check of there's another declaration and then resolve that as well + // This could be a var that points to another var that points to a known token for example. + // Since we haven't encountered this scenario yet, we'll leave this as a log entry + log(`no import specifier found for ${initializer.getText()}, it's a ${initializer.getKindName()}`); + } + } else if (Node.isTemplateExpression(initializer)) { + const templates = extractNodesFromTemplateStringLiteral(initializer); + const filteredExpressions = templates.extractedExpressions + .map((group) => { + const newGroup: TemplateGroupItem[] = []; + group.forEach((node) => { + const nodeInfo = isNodeToken(node, typeChecker, project); + if (nodeInfo?.isToken) { + newGroup.push({ + node, + actualTokenValue: nodeInfo.declarationValue, + }); + } + }); + return newGroup; + }) + .filter((group) => group.length > 0); + if (filteredExpressions.length > 0) { + return { + declaration: initializer, + sourceFile: initializer.getSourceFile(), + moduleSpecifier: '', + importExportSpecifierName: '', + valueDeclarationValue: initializer.getText(), + templateGroups: filteredExpressions, + }; + } + // from here we should filter the nodes to see if any of them are known token packages and then return the groups if they are still present. + // We'll need to filter each node group and then if no nodes in that group are found, we should remove the group. + } else if (Node.isPropertyAccessExpression(initializer)) { + log(`found property access ${initializer.getText()}, expression ${initializer.getExpression().getText()}`); + const expressionSymbol = initializer.getExpression().getSymbol(); + const expressionImportSpecifier = expressionSymbol?.getDeclarations().find(Node.isImportSpecifier); + if (expressionImportSpecifier) { + const expressionSpecifierName = expressionImportSpecifier.getName(); + const expressionImportDeclaration = expressionImportSpecifier.getFirstAncestorByKind( + SyntaxKind.ImportDeclaration + ); + const expressionModuleSpecifier = expressionImportDeclaration?.getModuleSpecifierValue(); + if ( + expressionModuleSpecifier !== undefined && + isKnownTokenPackage(expressionModuleSpecifier, expressionSpecifierName) + ) { + // found a known token, process + return { + declaration: expressionImportSpecifier, + sourceFile: expressionImportSpecifier.getSourceFile(), + moduleSpecifier: expressionModuleSpecifier, + importExportSpecifierName: expressionSpecifierName, + valueDeclarationValue: initializer.getText(), + }; + } else if ( + expressionModuleSpecifier !== undefined && + ts.isExternalModuleNameRelative(expressionModuleSpecifier) + ) { + const moduleSourceFile = getModuleSourceFile( + project, + expressionModuleSpecifier, + sourceFile.getFilePath() + ); + if (moduleSourceFile) { + return resolveExport(moduleSourceFile, expressionSpecifierName, typeChecker, project); + } + } + } + } + } + if (exportSpecifier) { + // If we have an export specifier, determine if we have a known token. + // If not, we need to find the module name and see if it's relative. If it is, we should recursively call this + // function to determine if there is a token. If it's not relative, we then should just end as the module + // isn't a token source. + const exportDeclaration = exportSpecifier.getFirstAncestorByKind(SyntaxKind.ExportDeclaration); + const exportSpecifierName = exportSpecifier.getName(); + if (exportDeclaration) { + const moduleSpecifier = exportDeclaration.getModuleSpecifierValue(); + if (moduleSpecifier !== undefined && isKnownTokenPackage(moduleSpecifier, exportSpecifierName)) { + // We found a known token source, return it + return { + declaration: exportSpecifier, + sourceFile: exportDeclaration.getSourceFile(), + moduleSpecifier, + importExportSpecifierName: exportSpecifierName, + valueDeclarationValue: exportSpecifierName, + }; + } else if (moduleSpecifier !== undefined && ts.isExternalModuleNameRelative(moduleSpecifier)) { + // We have a relative module specifier, we need to resolve it + const moduleSourceFile = getModuleSourceFile(project, moduleSpecifier, sourceFile.getFilePath()); + if (moduleSourceFile) { + return resolveExport(moduleSourceFile, exportSpecifierName, typeChecker, project); + } + } + } + } + if (exportAssignment) { + const exportExpression = exportAssignment.getExpression(); + const tokenInfo = isNodeToken(exportExpression, typeChecker, project); + if (tokenInfo?.isToken) { + return { + declaration: exportAssignment, + sourceFile: exportAssignment.getSourceFile(), + moduleSpecifier: '', + importExportSpecifierName: exportName, + valueDeclarationValue: tokenInfo.declarationValue ?? exportExpression.getText(), + }; + } + } + } + } catch (e) { + log(`Error resolving export ${exportName} in ${sourceFile.getFilePath()}:`, e); + return undefined; + } +} + +// Helper to avoid duplicating logic for property access and identifier token checks +function checkImportSpecifier( + importSpecifier: ImportSpecifier, + checker: TypeChecker, + project: Project, + sourceFilePath: string +): { isToken: boolean; declarationValue?: string } | undefined { + const importDeclaration = importSpecifier.getFirstAncestorByKind(SyntaxKind.ImportDeclaration); + const moduleSpecifier = importDeclaration?.getModuleSpecifierValue(); + const specifierName = importSpecifier.getName(); + if (moduleSpecifier !== undefined && isKnownTokenPackage(moduleSpecifier, specifierName)) { + return { isToken: true }; + } else if (moduleSpecifier !== undefined && ts.isExternalModuleNameRelative(moduleSpecifier)) { + const moduleSourceFile = getModuleSourceFile(project, moduleSpecifier, sourceFilePath); + if (moduleSourceFile) { + // If we have a relative module specifier, we need to resolve it and check if there's a token + // If there's a declaration value we should also return that so we don't falsely return the variable name + const resolverInfo = resolveExport(moduleSourceFile, specifierName, checker, project); + if (resolverInfo) { + return { isToken: true, declarationValue: resolverInfo.valueDeclarationValue }; + } + } + } +} + +const isNodeToken = ( + node: Node, + checker: TypeChecker, + project: Project +): { isToken: boolean; declarationValue?: string } | undefined => { + // Handle property access or identifier uniformly + let importSpecifier; + if (Node.isPropertyAccessExpression(node)) { + const symbol = checker.getSymbolAtLocation(node.getExpression()); + importSpecifier = symbol?.getDeclarations().find(Node.isImportSpecifier); + } else if (Node.isIdentifier(node)) { + const symbol = checker.getSymbolAtLocation(node); + importSpecifier = symbol?.getDeclarations().find(Node.isImportSpecifier); + } + if (importSpecifier) { + return checkImportSpecifier(importSpecifier, checker, project, node.getSourceFile().getFilePath()); + } +}; diff --git a/packages/token-analyzer/src/tokenResolver.ts b/packages/token-analyzer/src/tokenResolver.ts new file mode 100644 index 000000000..affdd657a --- /dev/null +++ b/packages/token-analyzer/src/tokenResolver.ts @@ -0,0 +1,293 @@ +import { + Node, + PropertyAssignment, + SpreadAssignment, + StringLiteral, + PropertyAccessExpression, + ObjectLiteralExpression, + CallExpression, + TemplateExpression, + Identifier, +} from 'ts-morph'; +import { TokenReference, TokenResolverInfo } from './types'; +import { + addTokenToArray, + getInitializerFromIdentifier, + getPropertiesForShorthand, + isTokenReference, +} from './tokenUtils'; +import { extractNodesFromTemplateStringLiteral } from './processTemplateStringLiteral'; + +/** + * Function that centarlizes the logic for resolving tokens from a node. + * Given that this is recursive logic, it's much easier to pass this back to itself. + * @param node + * @returns + */ +export const resolveToken = (info: TokenResolverInfo): TokenReference[] => { + const { node, tokens } = info; + + if (Node.isStringLiteral(node)) { + // Path in the event we need to process string literals, however this isn't used given tokens are stored as + // initialized values and imports. Generally, as property accessors or identifiers + // For now we'll leave a stub + return processStringLiteral(info as TokenResolverInfo); + } else if (Node.isTemplateExpression(node)) { + return processTemplateExpression(info as TokenResolverInfo); + } else if (Node.isIdentifier(node)) { + return processIdentifier(info as TokenResolverInfo); + } else if (Node.isPropertyAccessExpression(node)) { + return processPropertyAccess(info as TokenResolverInfo); + } else if (Node.isObjectLiteralExpression(node)) { + return processObjectLiteral(info as TokenResolverInfo); + } else if (Node.isSpreadAssignment(node)) { + return processSpreadAssignment(info as TokenResolverInfo); + } else if (Node.isCallExpression(node) && node.getExpression().getText() === 'createCustomFocusIndicatorStyle') { + return processFocusCallExpression(info as TokenResolverInfo); + } else if (Node.isCallExpression(node)) { + return processCallExpression(info as TokenResolverInfo); + } else if (Node.isPropertyAssignment(node)) { + return processPropertyAssignment(info as TokenResolverInfo); + } + + return tokens; +}; + +/** + * Stub for processing string literals which we don't need currently. + * @param node + * @returns + */ +const processStringLiteral = (info: TokenResolverInfo): TokenReference[] => { + return info.tokens; +}; + +const processIdentifier = (info: TokenResolverInfo): TokenReference[] => { + const { node, parentName, path, tokens, sourceFile, importedValues } = info; + + let returnTokens = tokens.slice(); + + const text = node.getText(); + const intializerNode = getInitializerFromIdentifier(node); + if (isTokenReference(info)) { + const propertyName = path[path.length - 1] ?? parentName; + const importedVal = importedValues.get(text)!; + // our template groups are already processed and we know they are known tokens so we can just add them + if (importedVal.templateGroups && importedVal.templateGroups.length > 0) { + importedVal.templateGroups.forEach((group) => { + const grouped: TokenReference = { property: propertyName, token: [], path }; + group.forEach((exprNode) => { + grouped.token.push(exprNode.actualTokenValue ?? exprNode.node.getText()); + }); + if (grouped.token.length > 0) { + returnTokens.push(grouped); + } + }); + } else { + returnTokens = addTokenToArray( + { + property: propertyName, + token: [importedVal.value], + path, + }, + returnTokens, + sourceFile + ); + } + return returnTokens; + } else if (intializerNode) { + // we have a variable declaration and we should then check if the value is a token as well. Reprocess the node + returnTokens = returnTokens.concat(resolveToken({ ...info, node: intializerNode })); + } + + return returnTokens; +}; + +const processPropertyAccess = (info: TokenResolverInfo): TokenReference[] => { + const { node, parentName, path, tokens, sourceFile } = info; + + const text = node.getText(); + const isToken = isTokenReference(info); + if (isToken) { + return addTokenToArray( + { + property: path[path.length - 1] ?? parentName, + token: [text], + path, + }, + tokens, + sourceFile + ); + } + return tokens; +}; + +const processObjectLiteral = (info: TokenResolverInfo): TokenReference[] => { + const { node, tokens } = info; + + let returnTokens = tokens.slice(); + node.getProperties().forEach((childProp) => { + returnTokens = returnTokens.concat( + resolveToken({ + ...info, + node: childProp, + }) + ); + }); + return returnTokens; +}; + +const processSpreadAssignment = (info: TokenResolverInfo): TokenReference[] => { + const { node, tokens } = info; + return tokens.concat( + resolveToken({ + ...info, + node: node.getExpression(), + }) + ); +}; + +const processFocusCallExpression = (info: TokenResolverInfo): TokenReference[] => { + const { node, path, parentName, tokens, importedValues } = info; + + const focus = `:focus`; + const focusWithin = `:focus-within`; + let nestedModifier = focus; + + const passedTokens = node.getArguments()[0]; + const passedOptions = node.getArguments()[1]; + + // Parse out the options being passed to the focus funuction and determine which selector is being used + if (passedOptions && Node.isObjectLiteralExpression(passedOptions)) { + passedOptions.getProperties().forEach((property) => { + if (Node.isPropertyAssignment(property)) { + const optionName = property.getName(); + if (optionName === 'selector') { + const selectorType = property.getInitializer()?.getText(); + if (selectorType === 'focus') { + nestedModifier = focus; + } else if (selectorType === 'focus-within') { + nestedModifier = focusWithin; + } + } + } + }); + } + + if (passedTokens) { + // We can simplify the logic since we process node types and extract within resolveTokens. We merely need to pass + // the updated path + return resolveToken({ + ...info, + node: passedTokens, + path: [...path, nestedModifier], + parentName, + tokens, + importedValues, + }); + } + + return tokens; +}; + +const processCallExpression = (info: TokenResolverInfo): TokenReference[] => { + const { node, path, tokens, importedValues, sourceFile } = info; + + let returnTokens = tokens.slice(); + // Process calls like shorthands.borderColor(tokens.color) + const functionName = node.getExpression().getText(); + + // check if we're using a shorthand function and get the output of a call based on parameters passed into the function + const affectedProperties = getPropertiesForShorthand(functionName, node.getArguments()); + + // If we have a shorthand function, we need to process the affected properties. + // getPropertiesForShorthand will return an array of objects + // with the property name and the token reference + // e.g. { property: 'borderColor', token: 'tokens.color' } + // It will also deeply check for initialized values etc and validate they are tokens + if (affectedProperties.length > 0) { + // Process each argument and apply it to all affected properties + affectedProperties.forEach((argument) => { + returnTokens = addTokenToArray( + { + property: argument.property, + token: [argument.token], + path: path.concat(argument.property), + }, + returnTokens, + sourceFile + ); + }); + } else { + // Generic handling of functions that are not whitelisted + node.getArguments().forEach((argument) => { + returnTokens = returnTokens.concat( + resolveToken({ + ...info, + node: argument, + path: [...path, functionName], + tokens: returnTokens, + importedValues, + }) + ); + }); + } + return returnTokens; +}; + +/** + * This is where we process template spans and feed it back into resolveToken. We also need to check that + * imported values are tokens etc. + * We also break down each individual group of fallbacks as multiple tokens in our output. So if a single property + * like shadow has a few var() functions, we should return each one as a separate token. We should do the same with + * separate token values in general. + * @param info + * @returns + */ +const processTemplateExpression = (info: TokenResolverInfo): TokenReference[] => { + const { node, path, parentName, tokens } = info; + const returnTokens = tokens.slice(); + + for (const expressions of extractNodesFromTemplateStringLiteral(node).extractedExpressions) { + // We should create a new token entry if we do indeed have tokens within our literal at this stage + const groupedTokens: TokenReference = { + property: path[path.length - 1] ?? parentName, + token: [], + path, + }; + for (const nestedExpression of expressions) { + const processedToken = resolveToken({ + ...info, + tokens: [], + node: nestedExpression, + }); + if (processedToken.length > 0) { + for (const token of processedToken) { + groupedTokens.token.push(...token.token); + } + } + } + + // If we have verified tokens (at least one), push them to the tokens array + // If this is empty, we only had expressions but no tokens. + if (groupedTokens.token.length > 0) { + returnTokens.push(groupedTokens); + } + } + + return returnTokens; +}; + +const processPropertyAssignment = (info: TokenResolverInfo): TokenReference[] => { + const { node, path } = info; + + const childName = node.getName(); + const newPath = [...path, childName]; + const propertyNode = node.getInitializer(); + + return resolveToken({ + ...info, + node: propertyNode ?? node, + path: newPath, + }); +}; diff --git a/packages/token-analyzer/src/tokenUtils.ts b/packages/token-analyzer/src/tokenUtils.ts new file mode 100644 index 000000000..7f9c00e57 --- /dev/null +++ b/packages/token-analyzer/src/tokenUtils.ts @@ -0,0 +1,216 @@ +// tokenUtils.ts +import { Symbol, SyntaxKind, Node, Expression } from 'ts-morph'; +import { knownTokenImportsAndModules, TOKEN_REGEX, TokenReference, TokenResolverInfo } from './types.js'; +import { shorthands } from '@griffel/react'; + +export function isTokenReference(info: TokenResolverInfo): boolean { + const { node, importedValues, project } = info; + let calledSymbol: Symbol | undefined; + let calledNodeName = node.getText(); + let importedSymbol: Symbol | undefined; + const checker = project.getTypeChecker(); + if (Node.isPropertyAccessExpression(node)) { + const expression = node.getExpression(); + calledNodeName = expression.getText(); + calledSymbol = checker.getSymbolAtLocation(expression); + } else { + calledSymbol = checker.getSymbolAtLocation(node); + } + + const knownTokenValue = importedValues.get(calledNodeName); + if (knownTokenValue) { + const knownTokenNode = knownTokenValue.node; + importedSymbol = checker.getSymbolAtLocation(knownTokenNode); + if (importedSymbol === undefined && Node.isImportSpecifier(knownTokenNode)) { + importedSymbol = checker.getSymbolAtLocation(knownTokenNode.getNameNode()); + } + } + + // If we have a known token that is equal to an imported value and both resolve we know it's a token + return calledSymbol !== undefined && importedSymbol !== undefined && calledSymbol === importedSymbol; +} + +/** + * 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 isTokenReferenceOld(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; +} + +export function getInitializerFromIdentifier(node: Node): Expression | undefined { + const nodeSymbol = node.getSymbol(); + const nodeDeclarations = nodeSymbol?.getDeclarations(); + if (nodeSymbol && nodeDeclarations && nodeDeclarations.length > 0) { + if (Node.isVariableDeclaration(nodeDeclarations[0])) { + const nodeInitializer = nodeDeclarations[0].getInitializer(); + if (nodeInitializer) { + return nodeInitializer; + } + } + } +} + +/** + * 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 | undefined; + const matches: string[] = []; + + if (typeof textOrNode === 'string') { + text = textOrNode; + } else if (Node.isNode(textOrNode) && Node.isTemplateExpression(textOrNode)) { + textOrNode.getTemplateSpans().forEach((span) => { + if (isTokenReferenceOld(span.getExpression().getText())) { + const token = span.getExpression().getText(); + matches.push(token); + } else { + const spanExpression = span.getExpression(); + if (spanExpression.getKind() === SyntaxKind.Identifier) { + const spanInitializer = getInitializerFromIdentifier(spanExpression); + if (spanInitializer) { + matches.push(...extractTokensFromText(spanInitializer)); + } + } + } + }); + } else if (Node.isNode(textOrNode)) { + // If we have an identifier, we need to check if it has an initializer. From there we should reprocess to extract tokens + if (Node.isIdentifier(textOrNode)) { + const initializer = getInitializerFromIdentifier(textOrNode); + if (initializer) { + matches.push(...extractTokensFromText(initializer)); + } + } else { + text = textOrNode.getText(); + } + } else { + // 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(); + } + + if (text !== undefined) { + const regMatch = text.match(TOKEN_REGEX); + if (regMatch) { + matches.push(...regMatch); + } + } + return matches; +} + +type FunctionParams = T extends (...args: infer P) => any ? P : never; +/** + * 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, args: Node[]): { property: string; token: string }[] { + // Extract base function name if it's a qualified name (e.g., shorthands.borderColor -> borderColor) + const baseName = functionName.includes('.') ? functionName.split('.').pop() : functionName; + + const cleanFunctionName = baseName as keyof typeof shorthands; + const shorthandFunction = shorthands[cleanFunctionName]; + if (shorthandFunction) { + const argValues = args.map( + // We have to extract the token from the argument in the case that there's a template literal, initializer, etc. + (arg) => extractTokensFromText(arg)[0] + ) as FunctionParams; + + // @ts-expect-error We have a very complex union type that is difficult/impossible to resolve statically. + const shortHandOutput = shorthandFunction(...argValues); + + // Once we have the shorthand output, we should process the values, sanitize them and then return only the properties + // that contain tokens. + const shortHandTokens: { property: string; token: string }[] = []; + + Object.keys(shortHandOutput).forEach((key) => { + const value = shortHandOutput[key as keyof typeof shortHandOutput]; + if (isTokenReferenceOld(value)) { + shortHandTokens.push({ + property: key, + token: value, + }); + } + }); + + return shortHandTokens; + } + + // The function didn't match any known shorthand functions so return an empty array. + return []; +} + +/** + * Centralized pure function to add tokens to an array of tokens. This is useful in the event we change the contract + * or if we have to do additional logic or processing. Without which we'd need to update 10+ locations. + * @param tokensToAdd + * @param target + * @returns + */ +export const addTokenToArray = ( + tokensToAdd: TokenReference[] | TokenReference, + target: TokenReference[], + sourceFile?: string +) => { + // create new array without modifying the original array + const newArray = target.slice(); + + // add items to the array + if (Array.isArray(tokensToAdd)) { + newArray.push( + ...tokensToAdd.map((token) => ({ + ...token, + ...(sourceFile && { sourceFile }), + })) + ); + } else { + newArray.push({ + ...tokensToAdd, + ...(sourceFile && { sourceFile }), + }); + } + + // return array without modifying the original array + return newArray; +}; + +export function isKnownTokenPackage(moduleSpecifier: string, valueName?: string): boolean { + const keys = Object.keys(knownTokenImportsAndModules); + return ( + (valueName && keys.includes(valueName) && knownTokenImportsAndModules[valueName].includes(moduleSpecifier)) || + knownTokenImportsAndModules.default.includes(moduleSpecifier) + ); +} diff --git a/packages/token-analyzer/src/types.ts b/packages/token-analyzer/src/types.ts new file mode 100644 index 000000000..085dc0742 --- /dev/null +++ b/packages/token-analyzer/src/types.ts @@ -0,0 +1,80 @@ +import { Project, Node } from 'ts-morph'; +import { ImportedValue } from './importAnalyzer'; + +// types.ts +export interface TokenReference { + property: string; + token: string[]; + path: string[]; + sourceFile?: string; +} + +export interface StyleContent { + tokens: TokenReference[]; + nested?: StyleTokens; + isResetStyles?: boolean; + assignedVariables?: string[]; +} + +export interface StyleTokens { + [key: string]: StyleContent; +} + +export interface StyleAnalysis { + [key: string]: StyleTokens; +} + +export interface StyleCondition { + style: string; + condition: string; +} + +export interface StyleMetadata { + styleConditions: { + [styleName: string]: { + isBase?: boolean; + conditions?: string[]; + slotName: string; + }; + }; +} + +export interface FileAnalysis { + styles: StyleAnalysis; + metadata: StyleMetadata; +} + +export interface AnalysisResults { + [filePath: string]: FileAnalysis; +} + +// Constants +export const TOKEN_REGEX = /tokens\.[a-zA-Z0-9.]+/g; +export const IGNORED_DIRS = ['node_modules', 'dist', 'build', '.git']; +export const VALID_EXTENSIONS = ['.ts', '.tsx', '.js', '.jsx', '.mjs']; + +export type TokenMap = Map; + +/** + * This type houses the known named token imports ex: `tokens` and the modules they are imported from. + */ +export type KnownTokenImportsAndModules = { + [key: string]: string[]; +}; + +export const knownTokenImportsAndModules: KnownTokenImportsAndModules = { + // if we see any imports from the defaults, we assume it's a token. + default: ['@fluentui/semantic-tokens'], + // begin the known token imports + tokens: ['@fluentui/react-theme', '@fluentui/react-components', '@fluentui/tokens'], +}; + +export interface TokenResolverInfo { + node: T; + path: string[]; + parentName: string; + tokens: TokenReference[]; + importedValues: Map; + project: Project; + sourceFile?: string; +} diff --git a/packages/token-analyzer/tsconfig.json b/packages/token-analyzer/tsconfig.json index a5d448b2f..2c11b804e 100644 --- a/packages/token-analyzer/tsconfig.json +++ b/packages/token-analyzer/tsconfig.json @@ -2,7 +2,10 @@ "extends": "../../tsconfig.base.json", "files": [], "compilerOptions": { - "jsx": "react" + "jsx": "react", + "esModuleInterop": true, + "module": "ESNext", // or "ES2020", "ES2022" + "moduleResolution": "node" }, "include": [], "references": [ diff --git a/packages/token-analyzer/tsconfig.lib.json b/packages/token-analyzer/tsconfig.lib.json index 884038eac..590b0063b 100644 --- a/packages/token-analyzer/tsconfig.lib.json +++ b/packages/token-analyzer/tsconfig.lib.json @@ -9,6 +9,10 @@ "jest.config.ts", "src/**/*.test.ts", "src/**/*.test.tsx", - "files/**" + "files/**", + "**/*.stories.ts", + "**/*.stories.js", + "**/*.stories.jsx", + "**/*.stories.tsx" ] } diff --git a/yarn.lock b/yarn.lock index f3febdb95..16018f7a3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2778,6 +2778,13 @@ "@fluentui/theme" "^2.6.67" tslib "^2.1.0" +"@fluentui/semantic-tokens@0.0.0-nightly-20250501-1704.1": + version "0.0.0-nightly-20250501-1704.1" + resolved "https://registry.yarnpkg.com/@fluentui/semantic-tokens/-/semantic-tokens-0.0.0-nightly-20250501-1704.1.tgz#8d96d8327153bd3218dd19efa20e7214211361c5" + integrity sha512-ILvDAU4ESViISImGLkET4FKBJWSRjiR9Q1Vn2vVE7b/hrvNfMslJBncQeiZTFF15VrB1XGEFP6tFwV8WcoFftg== + dependencies: + "@swc/helpers" "^0.5.1" + "@fluentui/set-version@^8.2.24": version "8.2.24" resolved "https://registry.yarnpkg.com/@fluentui/set-version/-/set-version-8.2.24.tgz#530d09eb4385bb298dd85a877cc492354fe93377" @@ -5860,6 +5867,15 @@ resolved "https://registry.yarnpkg.com/@trysound/sax/-/sax-0.2.0.tgz#cccaab758af56761eb7bf37af6f03f326dd798ad" integrity sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA== +"@ts-morph/common@~0.25.0": + version "0.25.0" + resolved "https://registry.yarnpkg.com/@ts-morph/common/-/common-0.25.0.tgz#b76cbd517118acc8eadaf12b2fc2d47f42923452" + integrity sha512-kMnZz+vGGHi4GoHnLmMhGNjm44kGtKUXGnOvrKmMwAuvNjM/PgKVGfUnL7IDvK7Jb2QQ82jq3Zmp04Gy+r3Dkg== + dependencies: + minimatch "^9.0.4" + path-browserify "^1.0.1" + tinyglobby "^0.2.9" + "@tsconfig/node10@^1.0.7": version "1.0.9" resolved "https://registry.yarnpkg.com/@tsconfig/node10/-/node10-1.0.9.tgz#df4907fc07a886922637b15e02d4cebc4c0021b2" @@ -6185,6 +6201,11 @@ resolved "https://registry.yarnpkg.com/@types/parse-json/-/parse-json-4.0.0.tgz#2f8bb441434d163b35fb8ffdccd7138927ffb8c0" integrity sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA== +"@types/prettier@^2.6.2": + version "2.7.3" + resolved "https://registry.yarnpkg.com/@types/prettier/-/prettier-2.7.3.tgz#3e51a17e291d01d17d3fc61422015a933af7a08f" + integrity sha512-+68kP9yzs4LMp7VNh8gdzMSPZFL44MLGqiHWvttYJe+6qnuVr4Ek9wSBQoveqY/r+LwjCcU29kNVkidwim+kYA== + "@types/pretty-hrtime@^1.0.0": version "1.0.1" resolved "https://registry.yarnpkg.com/@types/pretty-hrtime/-/pretty-hrtime-1.0.1.tgz#72a26101dc567b0d68fd956cf42314556e42d601" @@ -6326,10 +6347,10 @@ dependencies: "@types/yargs-parser" "*" -"@types/yargs@^17.0.8": - version "17.0.24" - resolved "https://registry.yarnpkg.com/@types/yargs/-/yargs-17.0.24.tgz#b3ef8d50ad4aa6aecf6ddc97c580a00f5aa11902" - integrity sha512-6i0aC7jV6QzQB8ne1joVZ0eSFIstHsCrobmOtghM11yGlH0j43FKL2UhWdELkyps0zuf7qVTUVCCR+tgSlyLLw== +"@types/yargs@^17.0.33", "@types/yargs@^17.0.8": + version "17.0.33" + resolved "https://registry.yarnpkg.com/@types/yargs/-/yargs-17.0.33.tgz#8c32303da83eec050a84b3c7ae7b9f922d13e32d" + integrity sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA== dependencies: "@types/yargs-parser" "*" @@ -8073,6 +8094,11 @@ co@^4.6.0: resolved "https://registry.yarnpkg.com/co/-/co-4.6.0.tgz#6ea6bdf3d853ae54ccb8e47bfa0bf3f9031fb184" integrity sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ== +code-block-writer@^13.0.3: + version "13.0.3" + resolved "https://registry.yarnpkg.com/code-block-writer/-/code-block-writer-13.0.3.tgz#90f8a84763a5012da7af61319dd638655ae90b5b" + integrity sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg== + collect-v8-coverage@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/collect-v8-coverage/-/collect-v8-coverage-1.0.1.tgz#cc2c8e94fc18bbdffe64d6534570c8a673b27f59" @@ -16584,7 +16610,7 @@ tinyexec@^0.3.2: resolved "https://registry.yarnpkg.com/tinyexec/-/tinyexec-0.3.2.tgz#941794e657a85e496577995c6eef66f53f42b3d2" integrity sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA== -tinyglobby@^0.2.12: +tinyglobby@^0.2.12, tinyglobby@^0.2.9: version "0.2.12" resolved "https://registry.yarnpkg.com/tinyglobby/-/tinyglobby-0.2.12.tgz#ac941a42e0c5773bd0b5d08f32de82e74a1a61b5" integrity sha512-qkf4trmKSIiMTs/E63cxH+ojC2unam7rJ0WrauAzpT3ECNTxGRMlaXxVbfxMUC/w0LaYk6jQ4y/nGR9uBO3tww== @@ -16717,6 +16743,14 @@ ts-loader@^9.3.1: micromatch "^4.0.0" semver "^7.3.4" +ts-morph@24.0.0, ts-morph@^24.0.0: + version "24.0.0" + resolved "https://registry.yarnpkg.com/ts-morph/-/ts-morph-24.0.0.tgz#6249b526ade40cf99c8803e7abdae6c65882e58e" + integrity sha512-2OAOg/Ob5yx9Et7ZX4CvTCc0UFoZHwLEJ+dpDPSUi5TgwwlTlX47w+iFRrEwzUZwYACjq83cgjS/Da50Ga37uw== + dependencies: + "@ts-morph/common" "~0.25.0" + code-block-writer "^13.0.3" + ts-node@10.9.2: version "10.9.2" resolved "https://registry.yarnpkg.com/ts-node/-/ts-node-10.9.2.tgz#70f021c9e185bccdca820e26dc413805c101c71f" @@ -17781,7 +17815,7 @@ yargs@^15.0.2: y18n "^4.0.0" yargs-parser "^18.1.2" -yargs@^17.3.1, yargs@^17.6.2: +yargs@^17.3.1, yargs@^17.6.2, yargs@^17.7.2: version "17.7.2" resolved "https://registry.yarnpkg.com/yargs/-/yargs-17.7.2.tgz#991df39aca675a192b816e1e0363f9d75d2aa269" integrity sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==