From 3c1ce3daf690c2d353103c069caecda4d5516b8f Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 6 Mar 2025 00:56:17 -0800 Subject: [PATCH 01/24] add some module res code and tests --- .../library/package.json | 3 +- .../src/__tests__/moduleResolver.test.ts | 197 ++++++++++++++++++ .../library/src/moduleResolver.ts | 149 +++++++++++++ 3 files changed, 348 insertions(+), 1 deletion(-) create mode 100644 packages/react-components/token-analyzer-preview/library/src/__tests__/moduleResolver.test.ts create mode 100644 packages/react-components/token-analyzer-preview/library/src/moduleResolver.ts diff --git a/packages/react-components/token-analyzer-preview/library/package.json b/packages/react-components/token-analyzer-preview/library/package.json index 361386bc33569..593a18a02e704 100644 --- a/packages/react-components/token-analyzer-preview/library/package.json +++ b/packages/react-components/token-analyzer-preview/library/package.json @@ -31,7 +31,8 @@ "@fluentui/react-utilities": "^9.18.16", "@griffel/react": "^1.5.22", "@swc/helpers": "^0.5.1", - "ts-morph": "24.0.0" + "ts-morph": "24.0.0", + "typescript": "5.2.2" }, "peerDependencies": { "@types/react": ">=16.14.0 <19.0.0", diff --git a/packages/react-components/token-analyzer-preview/library/src/__tests__/moduleResolver.test.ts b/packages/react-components/token-analyzer-preview/library/src/__tests__/moduleResolver.test.ts new file mode 100644 index 0000000000000..a5ef745fb4006 --- /dev/null +++ b/packages/react-components/token-analyzer-preview/library/src/__tests__/moduleResolver.test.ts @@ -0,0 +1,197 @@ +// moduleResolver.test.ts +import { ModuleResolutionKind, Project, ScriptTarget } from 'ts-morph'; +import { resolveModulePath, getModuleSourceFile, clearModuleCache } from '../moduleResolver'; +import * as path from 'path'; +import * as fs from 'fs'; +import * as ts from 'typescript'; + +// 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'; + `, + ); +}); + +afterAll(() => { + if (fs.existsSync(TEST_DIR)) { + fs.rmSync(TEST_DIR, { recursive: true, force: true }); + } +}); + +describe('Module resolver functions', () => { + let project: Project; + + beforeEach(() => { + // Create a fresh project for each test + project = new Project({ + compilerOptions: { + target: ScriptTarget.ES2020, + moduleResolution: ModuleResolutionKind.NodeNext, + }, + }); + + // Clear caches + clearModuleCache(); + }); + + describe('resolveModulePath', () => { + test('resolves relative path correctly', () => { + const sourceFilePath = path.join(TEST_DIR, 'source.ts'); + const result = resolveModulePath(project, './utils', sourceFilePath); + + expect(result).not.toBeNull(); + expect(result).toEqual(path.join(TEST_DIR, 'utils.ts')); + }); + + test('resolves nested relative path correctly', () => { + const sourceFilePath = path.join(TEST_DIR, 'source.ts'); + const result = resolveModulePath(project, './styles/theme', sourceFilePath); + + expect(result).not.toBeNull(); + expect(result).toEqual(path.join(TEST_DIR, 'styles/theme.ts')); + }); + + test('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 file system and TS resolution to verify cache is used + const originalGetFileSize = ts.sys.getFileSize; + const originalResolve = ts.resolveModuleName; + + ts.sys.getFileSize = jest.fn().mockImplementation(() => { + throw new Error('getFileSize should not be called if cache is working'); + }); + + ts.resolveModuleName = jest.fn().mockImplementation(() => { + throw new Error('resolveModuleName 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 functions + ts.sys.getFileSize = originalGetFileSize; + ts.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); + + // Clear caches + clearModuleCache(); + + // Mock TS resolution to verify cache is cleared + const originalResolve = require('typescript').resolveModuleName; + let resolveWasCalled = false; + require('typescript').resolveModuleName = jest.fn().mockImplementation((...args) => { + resolveWasCalled = true; + return originalResolve(...args); + }); + + // Call should not use cache + getModuleSourceFile(project, './utils', sourceFilePath); + expect(resolveWasCalled).toBe(true); + + // Restore original function + require('typescript').resolveModuleName = originalResolve; + }); +}); diff --git a/packages/react-components/token-analyzer-preview/library/src/moduleResolver.ts b/packages/react-components/token-analyzer-preview/library/src/moduleResolver.ts new file mode 100644 index 0000000000000..1cc6576cde9cc --- /dev/null +++ b/packages/react-components/token-analyzer-preview/library/src/moduleResolver.ts @@ -0,0 +1,149 @@ +import * as ts from 'typescript'; +import * as path from 'path'; +import { Project, SourceFile } from 'ts-morph'; +import { log } from './debugUtils.js'; + +// Cache for resolved module paths +const modulePathCache = new Map(); + +// Cache for resolved source files +const resolvedFilesCache = new Map(); + +/** + * Creates a cache key for module resolution + */ +function createCacheKey(moduleSpecifier: string, containingFile: string): string { + return `${containingFile}:${moduleSpecifier}`; +} + +/** + * Resolves a module specifier to an absolute file path using TypeScript's resolution + * + * @param project TypeScript project + * @param moduleSpecifier The module to resolve (e.g., './utils', 'react') + * @param containingFile The file containing the import + * @returns The absolute file path or null if it can't be resolved + */ +export function resolveModulePath(project: Project, moduleSpecifier: string, containingFile: string): string | null { + const cacheKey = createCacheKey(moduleSpecifier, containingFile); + + // Check cache first + if (modulePathCache.has(cacheKey)) { + return modulePathCache.get(cacheKey)!; + } + + // For relative paths, try a simple path resolution first + // This is a basic alternative to the non-existent resolveSourceFileDependency + if (moduleSpecifier.startsWith('.')) { + try { + const basePath = path.dirname(containingFile); + + // Try with extensions + const extensions = ['.ts', '.tsx', '.js', '.jsx', '.d.ts']; + for (const ext of extensions) { + const candidatePath = path.resolve(basePath, moduleSpecifier + ext); + try { + // Check if file exists + const stats = ts.sys.getFileSize?.(candidatePath); + if (stats !== undefined) { + modulePathCache.set(cacheKey, candidatePath); + return candidatePath; + } + } catch (e) { + // Continue to next extension + } + } + + // Try as directory with index file + for (const ext of extensions) { + const candidatePath = path.resolve(basePath, moduleSpecifier, 'index' + ext); + try { + const stats = ts.sys.getFileSize?.(candidatePath); + if (stats !== undefined) { + modulePathCache.set(cacheKey, candidatePath); + return candidatePath; + } + } catch (e) { + // Continue to next extension + } + } + } catch (e) { + // Fall through to TypeScript's module resolution + } + } + + // Use TypeScript's module resolution API + const result = ts.resolveModuleName( + moduleSpecifier, + containingFile, + project.getCompilerOptions() as ts.CompilerOptions, + ts.sys, + ); + + // Cache and return the result + if (result.resolvedModule) { + const resolvedPath = result.resolvedModule.resolvedFileName; + modulePathCache.set(cacheKey, resolvedPath); + return resolvedPath; + } + + // Cache negative result + modulePathCache.set(cacheKey, null); + return null; +} + +/** + * Gets a source file for a module specifier, resolving and adding it if needed + * + * @param project TypeScript project + * @param moduleSpecifier The module to resolve (e.g., './utils', 'react') + * @param containingFile The file containing the import + * @returns The resolved source file or null if it can't be resolved + */ +export function getModuleSourceFile( + project: Project, + moduleSpecifier: string, + containingFile: string, +): SourceFile | null { + log(`Resolving module: ${moduleSpecifier} from ${containingFile}`); + + // Step 1: Try to resolve the module to a file path + const resolvedPath = resolveModulePath(project, moduleSpecifier, containingFile); + if (!resolvedPath) { + log(`Could not resolve module: ${moduleSpecifier}`); + return null; + } + + // Step 2: Check if we already have this file + if (resolvedFilesCache.has(resolvedPath)) { + return resolvedFilesCache.get(resolvedPath)!; + } + + // Step 3: Get or add the file to the project + try { + // First try to get file if it's already in the project + let sourceFile = project.getSourceFile(resolvedPath); + + // If not found, add it + if (!sourceFile) { + sourceFile = project.addSourceFileAtPath(resolvedPath); + log(`Added source file: ${resolvedPath}`); + } + + // Cache the result + resolvedFilesCache.set(resolvedPath, sourceFile); + return sourceFile; + } catch (error) { + log(`Error adding source file: ${resolvedPath}`, error); + return null; + } +} + +/** + * Clears the module resolution caches + * Useful for testing or when analyzing multiple projects + */ +export function clearModuleCache(): void { + modulePathCache.clear(); + resolvedFilesCache.clear(); +} From e98b619c056047531872f2f50745a606de2467dc Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 6 Mar 2025 17:28:29 -0800 Subject: [PATCH 02/24] updated module resolver --- .../src/__tests__/moduleResolver.test.ts | 59 +++++++++++------ .../library/src/moduleResolver.ts | 66 +++++++++++++++---- 2 files changed, 91 insertions(+), 34 deletions(-) diff --git a/packages/react-components/token-analyzer-preview/library/src/__tests__/moduleResolver.test.ts b/packages/react-components/token-analyzer-preview/library/src/__tests__/moduleResolver.test.ts index a5ef745fb4006..afc2c12f52e0c 100644 --- a/packages/react-components/token-analyzer-preview/library/src/__tests__/moduleResolver.test.ts +++ b/packages/react-components/token-analyzer-preview/library/src/__tests__/moduleResolver.test.ts @@ -1,6 +1,6 @@ // moduleResolver.test.ts import { ModuleResolutionKind, Project, ScriptTarget } from 'ts-morph'; -import { resolveModulePath, getModuleSourceFile, clearModuleCache } from '../moduleResolver'; +import { resolveModulePath, getModuleSourceFile, clearModuleCache, tsUtils } from '../moduleResolver'; import * as path from 'path'; import * as fs from 'fs'; import * as ts from 'typescript'; @@ -49,6 +49,14 @@ beforeAll(() => { 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(() => { @@ -90,6 +98,14 @@ describe('Module resolver functions', () => { 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); @@ -104,25 +120,18 @@ describe('Module resolver functions', () => { const firstResult = resolveModulePath(project, './utils', sourceFilePath); expect(firstResult).not.toBeNull(); - // Mock the file system and TS resolution to verify cache is used - const originalGetFileSize = ts.sys.getFileSize; - const originalResolve = ts.resolveModuleName; - - ts.sys.getFileSize = jest.fn().mockImplementation(() => { - throw new Error('getFileSize should not be called if cache is working'); - }); - - ts.resolveModuleName = jest.fn().mockImplementation(() => { - throw new Error('resolveModuleName should not be called if cache is working'); + // 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 functions - ts.sys.getFileSize = originalGetFileSize; - ts.resolveModuleName = originalResolve; + // Restore original function + tsUtils.resolveModuleName = originalResolve; }); }); @@ -180,18 +189,28 @@ describe('Module resolver functions', () => { clearModuleCache(); // Mock TS resolution to verify cache is cleared - const originalResolve = require('typescript').resolveModuleName; + const originalResolve = tsUtils.resolveModuleName; let resolveWasCalled = false; - require('typescript').resolveModuleName = jest.fn().mockImplementation((...args) => { - resolveWasCalled = true; - return originalResolve(...args); - }); + tsUtils.resolveModuleName = jest + .fn() + .mockImplementation( + ( + moduleName: string, + containingFile: string, + compilerOptions: ts.CompilerOptions, + host: ts.ModuleResolutionHost, + ) => { + resolveWasCalled = true; + + return originalResolve(moduleName, containingFile, compilerOptions, host); + }, + ); // Call should not use cache getModuleSourceFile(project, './utils', sourceFilePath); expect(resolveWasCalled).toBe(true); // Restore original function - require('typescript').resolveModuleName = originalResolve; + tsUtils.resolveModuleName = originalResolve; }); }); diff --git a/packages/react-components/token-analyzer-preview/library/src/moduleResolver.ts b/packages/react-components/token-analyzer-preview/library/src/moduleResolver.ts index 1cc6576cde9cc..5bdc31a207e54 100644 --- a/packages/react-components/token-analyzer-preview/library/src/moduleResolver.ts +++ b/packages/react-components/token-analyzer-preview/library/src/moduleResolver.ts @@ -3,6 +3,18 @@ import * as path from 'path'; 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), +}; + // Cache for resolved module paths const modulePathCache = new Map(); @@ -33,32 +45,58 @@ export function resolveModulePath(project: Project, moduleSpecifier: string, con } // For relative paths, try a simple path resolution first - // This is a basic alternative to the non-existent resolveSourceFileDependency if (moduleSpecifier.startsWith('.')) { try { const basePath = path.dirname(containingFile); - - // Try with extensions const extensions = ['.ts', '.tsx', '.js', '.jsx', '.d.ts']; - for (const ext of extensions) { - const candidatePath = path.resolve(basePath, moduleSpecifier + ext); + + // 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); try { - // Check if file exists - const stats = ts.sys.getFileSize?.(candidatePath); + const stats = tsUtils.getFileSize(exactPath); if (stats !== undefined) { - modulePathCache.set(cacheKey, candidatePath); - return candidatePath; + modulePathCache.set(cacheKey, exactPath); + return exactPath; } } catch (e) { - // Continue to next extension + // If exact path fails, continue to extension-adding logic + } + } + + // 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); + try { + // Check if file exists + const stats = tsUtils.getFileSize(candidatePath); + if (stats !== undefined) { + modulePathCache.set(cacheKey, candidatePath); + return candidatePath; + } + } catch (e) { + // Continue to next extension + } } } - // Try as directory with index file + // 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(basePath, moduleSpecifier, 'index' + ext); + const candidatePath = path.resolve(dirPath, 'index' + ext); try { - const stats = ts.sys.getFileSize?.(candidatePath); + const stats = tsUtils.getFileSize(candidatePath); if (stats !== undefined) { modulePathCache.set(cacheKey, candidatePath); return candidatePath; @@ -73,7 +111,7 @@ export function resolveModulePath(project: Project, moduleSpecifier: string, con } // Use TypeScript's module resolution API - const result = ts.resolveModuleName( + const result = tsUtils.resolveModuleName( moduleSpecifier, containingFile, project.getCompilerOptions() as ts.CompilerOptions, From 0d6f371ab5f2791e70a8d21b2af3bfa7b26e4acd Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 7 Mar 2025 13:25:31 -0800 Subject: [PATCH 03/24] updates to module resolver --- .../src/__tests__/moduleResolver.test.ts | 25 ++++- .../src/__tests__/verifyFileExists.test.ts | 104 ++++++++++++++++++ .../library/src/moduleResolver.ts | 78 ++++++++----- 3 files changed, 175 insertions(+), 32 deletions(-) create mode 100644 packages/react-components/token-analyzer-preview/library/src/__tests__/verifyFileExists.test.ts diff --git a/packages/react-components/token-analyzer-preview/library/src/__tests__/moduleResolver.test.ts b/packages/react-components/token-analyzer-preview/library/src/__tests__/moduleResolver.test.ts index afc2c12f52e0c..075b627419c35 100644 --- a/packages/react-components/token-analyzer-preview/library/src/__tests__/moduleResolver.test.ts +++ b/packages/react-components/token-analyzer-preview/library/src/__tests__/moduleResolver.test.ts @@ -1,9 +1,17 @@ // moduleResolver.test.ts import { ModuleResolutionKind, Project, ScriptTarget } from 'ts-morph'; -import { resolveModulePath, getModuleSourceFile, clearModuleCache, tsUtils } from '../moduleResolver'; +import { + resolveModulePath, + getModuleSourceFile, + clearModuleCache, + tsUtils, + modulePathCache, + resolvedFilesCache, +} from '../moduleResolver'; import * as path from 'path'; import * as fs from 'fs'; import * as ts from 'typescript'; +import { log } from 'console'; // Setup test directory and files const TEST_DIR = path.join(__dirname, 'test-module-resolver'); @@ -185,13 +193,16 @@ describe('Module resolver functions', () => { getModuleSourceFile(project, './utils', sourceFilePath); getModuleSourceFile(project, './styles/theme', sourceFilePath); + log(modulePathCache, resolvedFilesCache, '================before'); + // Clear caches clearModuleCache(); + log(modulePathCache, resolvedFilesCache, '================after'); // Mock TS resolution to verify cache is cleared const originalResolve = tsUtils.resolveModuleName; let resolveWasCalled = false; - tsUtils.resolveModuleName = jest + const mockedModuleResolve = jest .fn() .mockImplementation( ( @@ -200,14 +211,24 @@ describe('Module resolver functions', () => { compilerOptions: ts.CompilerOptions, host: ts.ModuleResolutionHost, ) => { + log("==============================================why isn't this hit"); resolveWasCalled = true; return originalResolve(moduleName, containingFile, compilerOptions, host); }, ); + tsUtils.resolveModuleName = mockedModuleResolve; + + log( + 'is mocked call set properly', + tsUtils.resolveModuleName === mockedModuleResolve, + tsUtils.resolveModuleName === originalResolve, + ); + // Call should not use cache getModuleSourceFile(project, './utils', sourceFilePath); + log(modulePathCache, resolvedFilesCache, '================check cache'); expect(resolveWasCalled).toBe(true); // Restore original function diff --git a/packages/react-components/token-analyzer-preview/library/src/__tests__/verifyFileExists.test.ts b/packages/react-components/token-analyzer-preview/library/src/__tests__/verifyFileExists.test.ts new file mode 100644 index 0000000000000..a9f5937daa0f9 --- /dev/null +++ b/packages/react-components/token-analyzer-preview/library/src/__tests__/verifyFileExists.test.ts @@ -0,0 +1,104 @@ +// verifyFileExists.test.ts +import * as path from 'path'; +import * as fs from 'fs'; +import { tsUtils, verifyFileExists } from '../moduleResolver'; + +// Setup test directory and files +const TEST_DIR = path.join(__dirname, 'test-verify-files'); +const EXISTING_FILE = path.join(TEST_DIR, 'exists.txt'); +const NON_EXISTENT_FILE = path.join(TEST_DIR, 'does-not-exist.txt'); + +beforeAll(() => { + if (!fs.existsSync(TEST_DIR)) { + fs.mkdirSync(TEST_DIR, { recursive: true }); + } + + // Create a file we know exists + fs.writeFileSync(EXISTING_FILE, 'This file exists'); + + // Make sure our non-existent file really doesn't exist + if (fs.existsSync(NON_EXISTENT_FILE)) { + fs.unlinkSync(NON_EXISTENT_FILE); + } +}); + +afterAll(() => { + if (fs.existsSync(TEST_DIR)) { + fs.rmSync(TEST_DIR, { recursive: true, force: true }); + } +}); + +describe('verifyFileExists', () => { + // Store original functions to restore after tests + const originalFileExists = tsUtils.fileExists; + + afterEach(() => { + // Restore original functions after each test + tsUtils.fileExists = originalFileExists; + }); + + test('returns true for existing files', () => { + expect(verifyFileExists(EXISTING_FILE)).toBe(true); + }); + + test('returns false for non-existent files', () => { + expect(verifyFileExists(NON_EXISTENT_FILE)).toBe(false); + }); + + test('returns false for null or undefined paths', () => { + expect(verifyFileExists(null)).toBe(false); + expect(verifyFileExists(undefined)).toBe(false); + }); + + test('uses tsUtils.fileExists when available', () => { + // Mock the tsUtils.fileExists function + tsUtils.fileExists = jest.fn().mockImplementation(filePath => { + return filePath === EXISTING_FILE; + }); + + expect(verifyFileExists(EXISTING_FILE)).toBe(true); + expect(verifyFileExists(NON_EXISTENT_FILE)).toBe(false); + expect(tsUtils.fileExists).toHaveBeenCalledTimes(2); + }); + + test('falls back to fs.existsSync when tsUtils.fileExists throws', () => { + // Mock tsUtils.fileExists to throw an error + tsUtils.fileExists = jest.fn().mockImplementation(() => { + throw new Error('fileExists not available'); + }); + + // Spy on fs.existsSync + const existsSyncSpy = jest.spyOn(fs, 'existsSync'); + + // Test should still work using fs.existsSync + expect(verifyFileExists(EXISTING_FILE)).toBe(true); + expect(verifyFileExists(NON_EXISTENT_FILE)).toBe(false); + + // Verify tsUtils.fileExists was called and threw an error + expect(tsUtils.fileExists).toHaveBeenCalledTimes(2); + + // Verify fs.existsSync was used as fallback + expect(existsSyncSpy).toHaveBeenCalledTimes(2); + + // Restore the original spy + existsSyncSpy.mockRestore(); + }); + + test('returns false when both fileExists mechanisms fail', () => { + // Mock tsUtils.fileExists to throw + tsUtils.fileExists = jest.fn().mockImplementation(() => { + throw new Error('fileExists not available'); + }); + + // Mock fs.existsSync to throw + const existsSyncSpy = jest.spyOn(fs, 'existsSync').mockImplementation(() => { + throw new Error('existsSync not available'); + }); + + // Should safely return false when everything fails + expect(verifyFileExists(EXISTING_FILE)).toBe(false); + + // Restore the original spy + existsSyncSpy.mockRestore(); + }); +}); diff --git a/packages/react-components/token-analyzer-preview/library/src/moduleResolver.ts b/packages/react-components/token-analyzer-preview/library/src/moduleResolver.ts index 5bdc31a207e54..84653cc911665 100644 --- a/packages/react-components/token-analyzer-preview/library/src/moduleResolver.ts +++ b/packages/react-components/token-analyzer-preview/library/src/moduleResolver.ts @@ -1,5 +1,7 @@ +// 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'; @@ -13,13 +15,15 @@ export const tsUtils = { ) => 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 -const modulePathCache = new Map(); +export const modulePathCache = new Map(); // Cache for resolved source files -const resolvedFilesCache = new Map(); +export const resolvedFilesCache = new Map(); /** * Creates a cache key for module resolution @@ -28,6 +32,27 @@ function createCacheKey(moduleSpecifier: string, containingFile: string): string return `${containingFile}:${moduleSpecifier}`; } +/** + * Verifies a resolved file path actually exists + */ +function verifyFileExists(filePath: string | undefined | null): boolean { + if (!filePath) { + return false; + } + + try { + // Use TypeScript's file system abstraction for testing compatibility + return tsUtils.fileExists(filePath); + } catch (e) { + // If that fails, try Node's fs as fallback + try { + return fs.existsSync(filePath); + } catch (nestedE) { + return false; + } + } +} + /** * Resolves a module specifier to an absolute file path using TypeScript's resolution * @@ -41,6 +66,7 @@ export function resolveModulePath(project: Project, moduleSpecifier: string, con // Check cache first if (modulePathCache.has(cacheKey)) { + log("=========================this shouldn't be called"); return modulePathCache.get(cacheKey)!; } @@ -56,14 +82,9 @@ export function resolveModulePath(project: Project, moduleSpecifier: string, con // 1. If it has an extension, try the exact path first if (hasExtension) { const exactPath = path.resolve(basePath, moduleSpecifier); - try { - const stats = tsUtils.getFileSize(exactPath); - if (stats !== undefined) { - modulePathCache.set(cacheKey, exactPath); - return exactPath; - } - } catch (e) { - // If exact path fails, continue to extension-adding logic + if (verifyFileExists(exactPath)) { + modulePathCache.set(cacheKey, exactPath); + return exactPath; } } @@ -71,15 +92,9 @@ export function resolveModulePath(project: Project, moduleSpecifier: string, con if (!hasExtension) { for (const ext of extensions) { const candidatePath = path.resolve(basePath, moduleSpecifier + ext); - try { - // Check if file exists - const stats = tsUtils.getFileSize(candidatePath); - if (stats !== undefined) { - modulePathCache.set(cacheKey, candidatePath); - return candidatePath; - } - } catch (e) { - // Continue to next extension + if (verifyFileExists(candidatePath)) { + modulePathCache.set(cacheKey, candidatePath); + return candidatePath; } } } @@ -95,14 +110,9 @@ export function resolveModulePath(project: Project, moduleSpecifier: string, con for (const ext of extensions) { const candidatePath = path.resolve(dirPath, 'index' + ext); - try { - const stats = tsUtils.getFileSize(candidatePath); - if (stats !== undefined) { - modulePathCache.set(cacheKey, candidatePath); - return candidatePath; - } - } catch (e) { - // Continue to next extension + if (verifyFileExists(candidatePath)) { + modulePathCache.set(cacheKey, candidatePath); + return candidatePath; } } } catch (e) { @@ -118,14 +128,19 @@ export function resolveModulePath(project: Project, moduleSpecifier: string, con ts.sys, ); - // Cache and return the result + // Validate and cache the result if (result.resolvedModule) { const resolvedPath = result.resolvedModule.resolvedFileName; - modulePathCache.set(cacheKey, resolvedPath); - return resolvedPath; + + // 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; } @@ -185,3 +200,6 @@ export function clearModuleCache(): void { modulePathCache.clear(); resolvedFilesCache.clear(); } + +// Export for testing +export { verifyFileExists }; From 4265280a00d5c185b332c25407aa961b16212ee3 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 7 Mar 2025 16:18:26 -0800 Subject: [PATCH 04/24] fixing tests removing log --- .../src/__tests__/moduleResolver.test.ts | 44 +++---------------- .../library/src/moduleResolver.ts | 1 - 2 files changed, 6 insertions(+), 39 deletions(-) diff --git a/packages/react-components/token-analyzer-preview/library/src/__tests__/moduleResolver.test.ts b/packages/react-components/token-analyzer-preview/library/src/__tests__/moduleResolver.test.ts index 075b627419c35..1fe1364e1b1a7 100644 --- a/packages/react-components/token-analyzer-preview/library/src/__tests__/moduleResolver.test.ts +++ b/packages/react-components/token-analyzer-preview/library/src/__tests__/moduleResolver.test.ts @@ -10,8 +10,6 @@ import { } from '../moduleResolver'; import * as path from 'path'; import * as fs from 'fs'; -import * as ts from 'typescript'; -import { log } from 'console'; // Setup test directory and files const TEST_DIR = path.join(__dirname, 'test-module-resolver'); @@ -193,45 +191,15 @@ describe('Module resolver functions', () => { getModuleSourceFile(project, './utils', sourceFilePath); getModuleSourceFile(project, './styles/theme', sourceFilePath); - log(modulePathCache, resolvedFilesCache, '================before'); + // Verify caches were filled + expect(modulePathCache.size).toBeGreaterThan(0); + expect(resolvedFilesCache.size).toBeGreaterThan(0); // Clear caches clearModuleCache(); - log(modulePathCache, resolvedFilesCache, '================after'); - - // Mock TS resolution to verify cache is cleared - const originalResolve = tsUtils.resolveModuleName; - let resolveWasCalled = false; - const mockedModuleResolve = jest - .fn() - .mockImplementation( - ( - moduleName: string, - containingFile: string, - compilerOptions: ts.CompilerOptions, - host: ts.ModuleResolutionHost, - ) => { - log("==============================================why isn't this hit"); - resolveWasCalled = true; - - return originalResolve(moduleName, containingFile, compilerOptions, host); - }, - ); - - tsUtils.resolveModuleName = mockedModuleResolve; - - log( - 'is mocked call set properly', - tsUtils.resolveModuleName === mockedModuleResolve, - tsUtils.resolveModuleName === originalResolve, - ); - - // Call should not use cache - getModuleSourceFile(project, './utils', sourceFilePath); - log(modulePathCache, resolvedFilesCache, '================check cache'); - expect(resolveWasCalled).toBe(true); - // Restore original function - tsUtils.resolveModuleName = originalResolve; + // Directly verify caches are empty + expect(modulePathCache.size).toBe(0); + expect(resolvedFilesCache.size).toBe(0); }); }); diff --git a/packages/react-components/token-analyzer-preview/library/src/moduleResolver.ts b/packages/react-components/token-analyzer-preview/library/src/moduleResolver.ts index 84653cc911665..c41656a6b4424 100644 --- a/packages/react-components/token-analyzer-preview/library/src/moduleResolver.ts +++ b/packages/react-components/token-analyzer-preview/library/src/moduleResolver.ts @@ -66,7 +66,6 @@ export function resolveModulePath(project: Project, moduleSpecifier: string, con // Check cache first if (modulePathCache.has(cacheKey)) { - log("=========================this shouldn't be called"); return modulePathCache.get(cacheKey)!; } From e005fac803f35d8947a4d321eb3d82513a662fbd Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Tue, 11 Mar 2025 02:38:13 -0700 Subject: [PATCH 05/24] fix tests add import analyzer leverage TS APIs to find aliased values --- .../token-analyzer-preview/library/README.md | 1 + .../src/__tests__/packageImports.test.ts | 187 ++++++++++ .../src/__tests__/reexportTracking.test.ts | 177 ++++++++++ .../src/__tests__/typeCheckerImports.test.ts | 153 +++++++++ .../library/src/astAnalyzer.ts | 64 +++- .../library/src/importAnalyzer.ts | 318 ++++++++++++++++++ 6 files changed, 890 insertions(+), 10 deletions(-) create mode 100644 packages/react-components/token-analyzer-preview/library/src/__tests__/packageImports.test.ts create mode 100644 packages/react-components/token-analyzer-preview/library/src/__tests__/reexportTracking.test.ts create mode 100644 packages/react-components/token-analyzer-preview/library/src/__tests__/typeCheckerImports.test.ts create mode 100644 packages/react-components/token-analyzer-preview/library/src/importAnalyzer.ts diff --git a/packages/react-components/token-analyzer-preview/library/README.md b/packages/react-components/token-analyzer-preview/library/README.md index 0bc3d2109fc35..8240ad4066a4b 100644 --- a/packages/react-components/token-analyzer-preview/library/README.md +++ b/packages/react-components/token-analyzer-preview/library/README.md @@ -17,6 +17,7 @@ A static analysis tool that scans your project's style files to track and analyz - ~~assignedSlots in output to track which slots classes are applied to~~ - ~~Add variables full name to metadata (i.e. classNames.icon instead of just 'icon)~~ - Module importing +- Look at the path info again. Do we ever need it? ## Features diff --git a/packages/react-components/token-analyzer-preview/library/src/__tests__/packageImports.test.ts b/packages/react-components/token-analyzer-preview/library/src/__tests__/packageImports.test.ts new file mode 100644 index 0000000000000..5977f7b6b0a97 --- /dev/null +++ b/packages/react-components/token-analyzer-preview/library/src/__tests__/packageImports.test.ts @@ -0,0 +1,187 @@ +// packageImports.test.ts +import { Project, ModuleResolutionKind, ScriptTarget } from 'ts-morph'; +import { resolveModulePath, clearModuleCache, tsUtils } from '../moduleResolver'; +import * as path from 'path'; +import * as fs from 'fs'; + +// Setup test directory and mock node_modules structure +const TEST_DIR = path.join(__dirname, 'test-package-imports'); +const NODE_MODULES = path.join(TEST_DIR, 'node_modules'); +const SCOPED_PACKAGE = path.join(NODE_MODULES, '@scope', 'package'); +const REGULAR_PACKAGE = path.join(NODE_MODULES, 'some-package'); + +beforeAll(() => { + // Create test directories + if (!fs.existsSync(TEST_DIR)) { + fs.mkdirSync(TEST_DIR, { recursive: true }); + } + if (!fs.existsSync(SCOPED_PACKAGE)) { + fs.mkdirSync(SCOPED_PACKAGE, { recursive: true }); + } + if (!fs.existsSync(REGULAR_PACKAGE)) { + fs.mkdirSync(REGULAR_PACKAGE, { recursive: true }); + } + + // Create a source file that imports from packages + fs.writeFileSync( + path.join(TEST_DIR, 'source.ts'), + ` + import { Component } from '@scope/package'; + import { helper } from 'some-package'; + `, + ); + + // Create package.json and index files for the scoped package + fs.writeFileSync( + path.join(SCOPED_PACKAGE, 'package.json'), + JSON.stringify({ + name: '@scope/package', + version: '1.0.0', + main: 'index.js', + }), + ); + fs.writeFileSync( + path.join(SCOPED_PACKAGE, 'index.js'), + ` + export const Component = { + theme: 'tokens.components.primary' + }; + `, + ); + + // Create package.json and index files for the regular package + fs.writeFileSync( + path.join(REGULAR_PACKAGE, 'package.json'), + JSON.stringify({ + name: 'some-package', + version: '1.0.0', + main: './lib/index.js', + }), + ); + + // Create lib directory in the regular package + fs.mkdirSync(path.join(REGULAR_PACKAGE, 'lib'), { recursive: true }); + + fs.writeFileSync( + path.join(REGULAR_PACKAGE, 'lib', 'index.js'), + ` + export const helper = 'tokens.helpers.main'; + `, + ); +}); + +afterAll(() => { + if (fs.existsSync(TEST_DIR)) { + fs.rmSync(TEST_DIR, { recursive: true, force: true }); + } +}); + +describe('Package imports resolution', () => { + let project: Project; + let originalResolve: any; + let originalFileExists: any; + + beforeEach(() => { + project = new Project({ + compilerOptions: { + target: ScriptTarget.ES2020, + moduleResolution: ModuleResolutionKind.NodeNext, + }, + }); + + // Setup workspace + project.addSourceFileAtPath(path.join(TEST_DIR, 'source.ts')); + + // Clear caches + clearModuleCache(); + + // Store original functions + originalResolve = tsUtils.resolveModuleName; + originalFileExists = tsUtils.fileExists; + + // Mock fileExists to handle our mock node_modules + tsUtils.fileExists = jest.fn().mockImplementation((filePath: string) => { + return fs.existsSync(filePath); + }); + }); + + afterEach(() => { + // Restore original functions + tsUtils.resolveModuleName = originalResolve; + tsUtils.fileExists = originalFileExists; + }); + + test('resolves scoped package imports correctly', () => { + const sourceFilePath = path.join(TEST_DIR, 'source.ts'); + + // Mock the TypeScript resolution for scoped packages + tsUtils.resolveModuleName = jest + .fn() + .mockImplementation((moduleName: string, containingFile: string, compilerOptions: any, host: any) => { + if (moduleName === '@scope/package') { + return { + resolvedModule: { + resolvedFileName: path.join(SCOPED_PACKAGE, 'index.js'), + extension: '.js', + isExternalLibraryImport: true, + }, + }; + } + // Call original for other cases + return originalResolve(moduleName, containingFile, compilerOptions, host); + }); + + const result = resolveModulePath(project, '@scope/package', sourceFilePath); + + expect(result).not.toBeNull(); + expect(result).toEqual(path.join(SCOPED_PACKAGE, 'index.js')); + expect(tsUtils.resolveModuleName).toHaveBeenCalled(); + }); + + test('resolves regular package imports with non-standard main path', () => { + const sourceFilePath = path.join(TEST_DIR, 'source.ts'); + + // Mock the TypeScript resolution for regular packages + tsUtils.resolveModuleName = jest + .fn() + .mockImplementation((moduleName: string, containingFile: string, compilerOptions: any, host: any) => { + if (moduleName === 'some-package') { + return { + resolvedModule: { + resolvedFileName: path.join(REGULAR_PACKAGE, 'lib', 'index.js'), + extension: '.js', + isExternalLibraryImport: true, + }, + }; + } + // Call original for other cases + return originalResolve(moduleName, containingFile, compilerOptions, host); + }); + + const result = resolveModulePath(project, 'some-package', sourceFilePath); + + expect(result).not.toBeNull(); + expect(result).toEqual(path.join(REGULAR_PACKAGE, 'lib', 'index.js')); + expect(tsUtils.resolveModuleName).toHaveBeenCalled(); + }); + + test('returns null for non-existent packages', () => { + const sourceFilePath = path.join(TEST_DIR, 'source.ts'); + + // Mock the TypeScript resolution to return null for non-existent packages + tsUtils.resolveModuleName = jest + .fn() + .mockImplementation((moduleName: string, containingFile: string, compilerOptions: any, host: any) => { + if (moduleName === 'non-existent-package') { + return { resolvedModule: undefined }; + } + // Call original for other cases + return originalResolve(moduleName, containingFile, compilerOptions, host); + }); + + const result = resolveModulePath(project, 'non-existent-package', sourceFilePath); + + expect(result).toBeNull(); + expect(tsUtils.resolveModuleName).toHaveBeenCalled(); + }); +}); diff --git a/packages/react-components/token-analyzer-preview/library/src/__tests__/reexportTracking.test.ts b/packages/react-components/token-analyzer-preview/library/src/__tests__/reexportTracking.test.ts new file mode 100644 index 0000000000000..338deae748b7b --- /dev/null +++ b/packages/react-components/token-analyzer-preview/library/src/__tests__/reexportTracking.test.ts @@ -0,0 +1,177 @@ +// reexportTracking.test.ts +import { Project } from 'ts-morph'; +import { analyzeImports, ImportedValue } from '../importAnalyzer'; +import * as path from 'path'; +import * as fs from 'fs'; + +// Setup test directory with a chain of re-exports +const TEST_DIR = path.join(__dirname, 'test-reexports'); + +beforeAll(() => { + if (!fs.existsSync(TEST_DIR)) { + fs.mkdirSync(TEST_DIR, { recursive: true }); + } + + // Create a main file that imports from an index + fs.writeFileSync( + path.join(TEST_DIR, 'main.ts'), + ` + import { Component, AliasedValue, Utils, DirectValue } from './index'; + import DefaultExport from './index'; + + const styles = { + component: Component, + alias: AliasedValue, + utils: Utils, + direct: DirectValue, + default: DefaultExport + }; + `, + ); + + // Create an index file that re-exports everything + fs.writeFileSync( + path.join(TEST_DIR, 'index.ts'), + ` + // Re-export from components + export { Component } from './components'; + + // Re-export with alias + export { Value as AliasedValue } from './values'; + + // Re-export all from utils + export * from './utils'; + + // Direct export + export const DirectValue = 'tokens.direct.value'; + + // Re-export default + export { default } from './defaults'; + `, + ); + + // Create a components file + fs.writeFileSync( + path.join(TEST_DIR, 'components.ts'), + ` + export const Component = 'tokens.components.primary'; + `, + ); + + // Create a values file + fs.writeFileSync( + path.join(TEST_DIR, 'values.ts'), + ` + export const Value = 'tokens.values.standard'; + `, + ); + + // Create a utils file + fs.writeFileSync( + path.join(TEST_DIR, 'utils.ts'), + ` + export const Utils = 'tokens.utils.helper'; + `, + ); + + // Create a defaults file + fs.writeFileSync( + path.join(TEST_DIR, 'defaults.ts'), + ` + const DefaultValue = 'tokens.defaults.main'; + export default DefaultValue; + `, + ); +}); + +afterAll(() => { + if (fs.existsSync(TEST_DIR)) { + fs.rmSync(TEST_DIR, { recursive: true, force: true }); + } +}); + +describe('Re-export tracking', () => { + let project: Project; + + beforeEach(() => { + // Create a project using the existing directory structure + // This makes it easier to test without needing to override compiler options + project = new Project({ + tsConfigFilePath: path.join(TEST_DIR, '../../../tsconfig.json'), + skipAddingFilesFromTsConfig: true, + }); + + // Create a minimal tsconfig.json + fs.writeFileSync( + path.join(TEST_DIR, 'tsconfig.json'), + JSON.stringify({ + compilerOptions: { + target: 'es2020', + moduleResolution: 'node', + esModuleInterop: true, + skipLibCheck: true, + }, + }), + ); + }); + + test('follows standard re-export chain', async () => { + const mainFile = path.join(TEST_DIR, 'main.ts'); + const sourceFile = project.addSourceFileAtPath(mainFile); + + const importedValues: Map = await analyzeImports(sourceFile, project); + + // Check that Component was correctly resolved from components.ts + expect(importedValues.has('Component')).toBe(true); + expect(importedValues.get('Component')?.value).toBe('tokens.components.primary'); + expect(importedValues.get('Component')?.sourceFile).toContain('components.ts'); + }); + + test('follows aliased re-export chain', async () => { + const mainFile = path.join(TEST_DIR, 'main.ts'); + const sourceFile = project.addSourceFileAtPath(mainFile); + + const importedValues: Map = await analyzeImports(sourceFile, project); + + // Check that AliasedValue was correctly resolved from values.ts + expect(importedValues.has('AliasedValue')).toBe(true); + expect(importedValues.get('AliasedValue')?.value).toBe('tokens.values.standard'); + expect(importedValues.get('AliasedValue')?.sourceFile).toContain('values.ts'); + }); + + test('follows namespace re-export', async () => { + const mainFile = path.join(TEST_DIR, 'main.ts'); + const sourceFile = project.addSourceFileAtPath(mainFile); + + const importedValues: Map = await analyzeImports(sourceFile, project); + + // Check that Utils from namespace export was correctly resolved + expect(importedValues.has('Utils')).toBe(true); + expect(importedValues.get('Utils')?.value).toBe('tokens.utils.helper'); + expect(importedValues.get('Utils')?.sourceFile).toContain('utils.ts'); + }); + + test('handles direct exports in the same file', async () => { + const mainFile = path.join(TEST_DIR, 'main.ts'); + const sourceFile = project.addSourceFileAtPath(mainFile); + + const importedValues: Map = await analyzeImports(sourceFile, project); + + // Check that DirectValue was correctly resolved from index.ts + expect(importedValues.has('DirectValue')).toBe(true); + expect(importedValues.get('DirectValue')?.value).toBe('tokens.direct.value'); + expect(importedValues.get('DirectValue')?.sourceFile).toContain('index.ts'); + }); + + test('follows default export chain', async () => { + const mainFile = path.join(TEST_DIR, 'main.ts'); + const sourceFile = project.addSourceFileAtPath(mainFile); + + const importedValues: Map = await analyzeImports(sourceFile, project); + + // Check that DefaultExport was correctly resolved from defaults.ts + expect(importedValues.has('DefaultExport')).toBe(true); + expect(importedValues.get('DefaultExport')?.value).toBe('tokens.defaults.main'); + expect(importedValues.get('DefaultExport')?.sourceFile).toContain('defaults.ts'); + }); +}); diff --git a/packages/react-components/token-analyzer-preview/library/src/__tests__/typeCheckerImports.test.ts b/packages/react-components/token-analyzer-preview/library/src/__tests__/typeCheckerImports.test.ts new file mode 100644 index 0000000000000..9c5ab0e0f4c57 --- /dev/null +++ b/packages/react-components/token-analyzer-preview/library/src/__tests__/typeCheckerImports.test.ts @@ -0,0 +1,153 @@ +// typeCheckerImports.test.ts +import { Project } from 'ts-morph'; +import { analyzeImports, ImportedValue } from '../importAnalyzer'; +import * as path from 'path'; +import * as fs from 'fs'; + +// Setup test directory with a chain of re-exports +const TEST_DIR = path.join(__dirname, 'test-type-checker'); + +beforeAll(() => { + if (!fs.existsSync(TEST_DIR)) { + fs.mkdirSync(TEST_DIR, { recursive: true }); + } + + // Create a main file that imports from an index + fs.writeFileSync( + path.join(TEST_DIR, 'main.ts'), + ` + import { Component, AliasedValue, Utils, DirectValue } from './index'; + import DefaultExport from './index'; + + const styles = { + component: Component, + alias: AliasedValue, + utils: Utils, + direct: DirectValue, + default: DefaultExport + }; + `, + ); + + // Create an index file that re-exports everything + fs.writeFileSync( + path.join(TEST_DIR, 'index.ts'), + ` + // Re-export from components + export { Component } from './components'; + + // Re-export with alias + export { Value as AliasedValue } from './values'; + + // Re-export all from utils + export * from './utils'; + + // Direct export + export const DirectValue = 'tokens.direct.value'; + + // Re-export default + export { default } from './defaults'; + `, + ); + + // Create a components file + fs.writeFileSync( + path.join(TEST_DIR, 'components.ts'), + ` + export const Component = 'tokens.components.primary'; + `, + ); + + // Create a values file + fs.writeFileSync( + path.join(TEST_DIR, 'values.ts'), + ` + export const Value = 'tokens.values.standard'; + `, + ); + + // Create a utils file + fs.writeFileSync( + path.join(TEST_DIR, 'utils.ts'), + ` + export const Utils = 'tokens.utils.helper'; + `, + ); + + // Create a defaults file + fs.writeFileSync( + path.join(TEST_DIR, 'defaults.ts'), + ` + const DefaultValue = 'tokens.defaults.main'; + export default DefaultValue; + `, + ); +}); + +afterAll(() => { + if (fs.existsSync(TEST_DIR)) { + fs.rmSync(TEST_DIR, { recursive: true, force: true }); + } +}); + +describe('Type Checker Import Analysis', () => { + let project: Project; + + beforeEach(() => { + // Create a project using the existing directory structure + // This makes it easier to test without needing to override compiler options + project = new Project({ + tsConfigFilePath: path.join(TEST_DIR, '../../../tsconfig.json'), + skipAddingFilesFromTsConfig: true, + }); + + // Create a minimal tsconfig.json + fs.writeFileSync( + path.join(TEST_DIR, 'tsconfig.json'), + JSON.stringify({ + compilerOptions: { + target: 'es2020', + moduleResolution: 'node', + esModuleInterop: true, + skipLibCheck: true, + }, + }), + ); + }); + + test('follows all re-export types using type checker', async () => { + const mainFile = path.join(TEST_DIR, 'main.ts'); + const sourceFile = project.addSourceFileAtPath(mainFile); + + // Add all other files to ensure project has complete type information + project.addSourceFilesAtPaths([path.join(TEST_DIR, '**/*.ts')]); + + const importedValues: Map = await analyzeImports(sourceFile, project); + console.log(importedValues); + + // Verify standard re-export (Component) + expect(importedValues.has('Component')).toBe(true); + expect(importedValues.get('Component')?.value).toBe('tokens.components.primary'); + expect(importedValues.get('Component')?.sourceFile).toContain('components.ts'); + + // Verify aliased re-export (AliasedValue) + expect(importedValues.has('AliasedValue')).toBe(true); + expect(importedValues.get('AliasedValue')?.value).toBe('tokens.values.standard'); + expect(importedValues.get('AliasedValue')?.sourceFile).toContain('values.ts'); + + // Verify namespace re-export (Utils) + expect(importedValues.has('Utils')).toBe(true); + expect(importedValues.get('Utils')?.value).toBe('tokens.utils.helper'); + expect(importedValues.get('Utils')?.sourceFile).toContain('utils.ts'); + + // Verify direct export (DirectValue) + expect(importedValues.has('DirectValue')).toBe(true); + expect(importedValues.get('DirectValue')?.value).toBe('tokens.direct.value'); + expect(importedValues.get('DirectValue')?.sourceFile).toContain('index.ts'); + + // Verify default export (DefaultExport) + expect(importedValues.has('DefaultExport')).toBe(true); + expect(importedValues.get('DefaultExport')?.value).toBe('tokens.defaults.main'); + expect(importedValues.get('DefaultExport')?.sourceFile).toContain('defaults.ts'); + }); +}); diff --git a/packages/react-components/token-analyzer-preview/library/src/astAnalyzer.ts b/packages/react-components/token-analyzer-preview/library/src/astAnalyzer.ts index 06f382a10f814..fc6a1b4ee8332 100644 --- a/packages/react-components/token-analyzer-preview/library/src/astAnalyzer.ts +++ b/packages/react-components/token-analyzer-preview/library/src/astAnalyzer.ts @@ -11,6 +11,7 @@ import { StyleTokens, } from './types.js'; import { log, measure, measureAsync } from './debugUtils.js'; +import { analyzeImports, processImportedStringTokens, ImportedValue } from './importAnalyzer.js'; const makeResetStylesToken = 'resetStyles'; @@ -29,8 +30,16 @@ interface VariableMapping { * Process a style property to extract token references. * Property names are derived from the actual CSS property in the path, * not the object key containing them. + * + * @param prop The property assignment to process + * @param importedValues Map of imported values for resolving token references + * @param isResetStyles Whether this is a reset styles property */ -function processStyleProperty(prop: PropertyAssignment, isResetStyles?: Boolean): TokenReference[] { +function processStyleProperty( + prop: PropertyAssignment, + importedValues: Map | undefined = undefined, + isResetStyles?: Boolean, +): TokenReference[] { const tokens: TokenReference[] = []; const parentName = prop.getName(); @@ -44,8 +53,22 @@ function processStyleProperty(prop: PropertyAssignment, isResetStyles?: Boolean) path.push(parentName); } - if (Node.isStringLiteral(node) || Node.isIdentifier(node)) { + if (Node.isStringLiteral(node)) { + const text = node.getText().replace(/['"]/g, ''); // Remove quotes + const matches = text.match(TOKEN_REGEX); + if (matches) { + matches.forEach(match => { + tokens.push({ + property: path[path.length - 1] || parentName, + token: match, + path, + }); + }); + } + } else if (Node.isIdentifier(node)) { const text = node.getText(); + + // First check if it matches the token regex directly const matches = text.match(TOKEN_REGEX); if (matches) { matches.forEach(match => { @@ -56,6 +79,18 @@ function processStyleProperty(prop: PropertyAssignment, isResetStyles?: Boolean) }); }); } + + // Then check if it's an imported value reference + if (importedValues && importedValues.has(text)) { + const importTokens = processImportedStringTokens( + importedValues, + path[path.length - 1] || parentName, + text, + path, + TOKEN_REGEX, + ); + tokens.push(...importTokens); + } } else if (Node.isPropertyAccessExpression(node)) { const text = node.getText(); if (text.startsWith('tokens.')) { @@ -100,7 +135,6 @@ function processStyleProperty(prop: PropertyAssignment, isResetStyles?: Boolean) passedTokens.getProperties().forEach(property => { if (Node.isPropertyAssignment(property)) { const childName = property.getName(); - console.log('Get child name:', childName); processNode(property.getInitializer(), [...path, nestedModifier, childName]); } }); @@ -128,6 +162,7 @@ function processStyleProperty(prop: PropertyAssignment, isResetStyles?: Boolean) return tokens; } + /** * Analyzes mergeClasses calls to determine style relationships */ @@ -264,7 +299,10 @@ function createMetadata(styleMappings: StyleMapping[]): StyleMetadata { /** * Analyzes makeStyles calls to get token usage and structure */ -async function analyzeMakeStyles(sourceFile: SourceFile): Promise { +async function analyzeMakeStyles( + sourceFile: SourceFile, + importedValues: Map | undefined = undefined, +): Promise { const analysis: StyleAnalysis = {}; sourceFile.forEachDescendant(node => { @@ -276,7 +314,7 @@ async function analyzeMakeStyles(sourceFile: SourceFile): Promise stylesArg.getProperties().forEach(prop => { if (Node.isPropertyAssignment(prop)) { const styleName = prop.getName(); - const tokens = processStyleProperty(prop); + const tokens = processStyleProperty(prop, importedValues); const functionName = parentNode.getName(); if (!analysis[functionName]) { analysis[functionName] = {}; @@ -306,7 +344,7 @@ async function analyzeMakeStyles(sourceFile: SourceFile): Promise // Process the styles object stylesArg.getProperties().forEach(prop => { if (Node.isPropertyAssignment(prop)) { - const tokens = processStyleProperty(prop, true); + const tokens = processStyleProperty(prop, importedValues, true); if (tokens.length) { const styleContent = createStyleContent(tokens); analysis[functionName][makeResetStylesToken].tokens = analysis[functionName][ @@ -359,18 +397,24 @@ async function analyzeMakeStyles(sourceFile: SourceFile): Promise } /** - * Combines mergeClasses and makeStyles analysis + * Combines mergeClasses and makeStyles analysis, with import resolution */ async function analyzeFile(filePath: string, project: Project): Promise { log(`Analyzing ${filePath}`); const sourceFile = project.addSourceFileAtPath(filePath); - // First pass: Analyze mergeClasses + // First analyze imports to find imported string values + log('Analyzing imports to find imported token values'); + const importedValues = await measureAsync('analyze imports', () => analyzeImports(sourceFile, project)); + + // Second pass: Analyze mergeClasses const styleMappings = measure('analyze mergeClasses', () => analyzeMergeClasses(sourceFile)); - // Second pass: Analyze makeStyles - const styleAnalysis = await measureAsync('analyze makeStyles', () => analyzeMakeStyles(sourceFile)); + // Third pass: Analyze makeStyles with imported values + const styleAnalysis = await measureAsync('analyze makeStyles', () => + analyzeMakeStyles(sourceFile, importedValues), + ); // Create enhanced analysis with separated styles and metadata return { diff --git a/packages/react-components/token-analyzer-preview/library/src/importAnalyzer.ts b/packages/react-components/token-analyzer-preview/library/src/importAnalyzer.ts new file mode 100644 index 0000000000000..4d2d7c03847d0 --- /dev/null +++ b/packages/react-components/token-analyzer-preview/library/src/importAnalyzer.ts @@ -0,0 +1,318 @@ +// importAnalyzer.ts +import { Project, Node, SourceFile, ImportDeclaration, Symbol, TypeChecker, SyntaxKind } from 'ts-morph'; +import { log } from './debugUtils.js'; +import { TokenReference } from './types.js'; +import { getModuleSourceFile } from './moduleResolver.js'; + +/** + * Represents a value imported from another module + */ +export interface ImportedValue { + value: string; + sourceFile: string; + isLiteral: boolean; +} + +/** + * Analyzes imports in a source file to extract string values + */ +export async function analyzeImports(sourceFile: SourceFile, project: Project): Promise> { + const importedValues = new Map(); + const filePath = sourceFile.getFilePath(); + + log(`Analyzing imports in ${filePath}`); + + // Get TypeScript's type checker + const typeChecker = project.getTypeChecker(); + + // Process all import declarations + for (const importDecl of sourceFile.getImportDeclarations()) { + try { + // Process the import declaration + await processImportDeclaration(importDecl, sourceFile, project, importedValues, typeChecker); + } catch (err) { + log(`Error processing import: ${importDecl.getModuleSpecifierValue()}`, err); + } + } + + return importedValues; +} + +/** + * Process a single import declaration + */ +async function processImportDeclaration( + importDecl: ImportDeclaration, + sourceFile: SourceFile, + project: Project, + importedValues: Map, + typeChecker: TypeChecker, +): Promise { + const moduleSpecifier = importDecl.getModuleSpecifierValue(); + const containingFilePath = sourceFile.getFilePath(); + + // Use our module resolver to get the imported file + const importedFile = getModuleSourceFile(project, moduleSpecifier, containingFilePath); + + if (!importedFile) { + log(`Could not resolve module: ${moduleSpecifier}`); + return; + } + + // Process named imports (import { x } from 'module') + processNamedImports(importDecl, importedFile, project, importedValues, typeChecker); + + // Process default import (import x from 'module') + processDefaultImport(importDecl, importedFile, project, importedValues, typeChecker); +} + +/** + * Process named imports using TypeScript's type checker to follow re-exports + */ +function processNamedImports( + importDecl: ImportDeclaration, + importedFile: SourceFile, + project: Project, + importedValues: Map, + typeChecker: TypeChecker, +): void { + for (const namedImport of importDecl.getNamedImports()) { + const importName = namedImport.getName(); + const alias = namedImport.getAliasNode()?.getText() || importName; + + // Find the export's true source using TypeScript's type checker + const exportInfo = findExportDeclaration(importedFile, importName, typeChecker); + + if (exportInfo) { + const { declaration, sourceFile: declarationFile } = exportInfo; + + // Extract the value from the declaration + const valueInfo = extractValueFromDeclaration(declaration); + + if (valueInfo) { + importedValues.set(alias, { + value: valueInfo.value, + sourceFile: declarationFile.getFilePath(), + isLiteral: valueInfo.isLiteral, + }); + + log(`Added imported value: ${alias} = ${valueInfo.value} from ${declarationFile.getFilePath()}`); + } + } + } +} + +/** + * Process default import using TypeScript's type checker + */ +function processDefaultImport( + importDecl: ImportDeclaration, + importedFile: SourceFile, + project: Project, + importedValues: Map, + typeChecker: TypeChecker, +): void { + const defaultImport = importDecl.getDefaultImport(); + if (!defaultImport) { + return; + } + + const importName = defaultImport.getText(); + + // Find the default export's true source + const exportInfo = findExportDeclaration(importedFile, 'default', typeChecker); + + if (exportInfo) { + const { declaration, sourceFile: declarationFile } = exportInfo; + + // Extract the value from the declaration + const valueInfo = extractValueFromDeclaration(declaration); + + if (valueInfo) { + importedValues.set(importName, { + value: valueInfo.value, + sourceFile: declarationFile.getFilePath(), + isLiteral: valueInfo.isLiteral, + }); + + log(`Added default import: ${importName} = ${valueInfo.value} from ${declarationFile.getFilePath()}`); + } + } +} + +/** + * Find an export's original declaration using TypeScript's type checker + */ +function findExportDeclaration( + sourceFile: SourceFile, + exportName: string, + typeChecker: TypeChecker, +): { declaration: Node; sourceFile: SourceFile } | undefined { + try { + // Get the source file's symbol (represents the module) + const sourceFileSymbol = typeChecker.getSymbolAtLocation(sourceFile); + if (!sourceFileSymbol) { + log(`No symbol found for source file ${sourceFile.getFilePath()}`); + return undefined; + } + + // Get all exports from this module + const exports = typeChecker.getExportsOfModule(sourceFileSymbol); + if (!exports || exports.length === 0) { + log(`No exports found in module ${sourceFile.getFilePath()}`); + return undefined; + } + + // Find the specific export we're looking for + const exportSymbol = exports.find((symbol: Symbol) => symbol.getName() === exportName); + if (!exportSymbol) { + log(`Export symbol '${exportName}' not found in ${sourceFile.getFilePath()}`); + return undefined; + } + + // If this is an alias (re-export), get the original symbol + let resolvedSymbol: Symbol = exportSymbol; + if (exportSymbol.isAlias()) { + // we're ok type casting here because we know the symbol is an alias from the previous check but TS won't pick up on it + resolvedSymbol = typeChecker.getAliasedSymbol(exportSymbol) as Symbol; + log(`Resolved alias to: ${resolvedSymbol.getName()}`); + } + + // Get the value declaration from the resolved symbol + const valueDeclaration = resolvedSymbol.getValueDeclaration(); + if (!valueDeclaration) { + log(`No value declaration found for ${exportName}`); + + // Fallback to any declaration if value declaration is not available + const declarations = resolvedSymbol.getDeclarations(); + if (!declarations || declarations.length === 0) { + log(`No declarations found for ${exportName}`); + return undefined; + } + + const declaration = declarations[0]; + const declarationSourceFile = declaration.getSourceFile(); + + return { + declaration, + sourceFile: declarationSourceFile, + }; + } + + const declarationSourceFile = valueDeclaration.getSourceFile(); + + log( + `Found declaration for '${exportName}': ${valueDeclaration.getKindName()} in ${declarationSourceFile.getFilePath()}`, + ); + return { + declaration: valueDeclaration, + sourceFile: declarationSourceFile, + }; + } catch (err) { + log(`Error finding export declaration for ${exportName}:`, err); + return undefined; + } +} + +/** + * Extract string value from a declaration node + */ +function extractValueFromDeclaration(declaration: Node): { value: string; isLiteral: boolean } | undefined { + // Handle variable declarations + if (Node.isVariableDeclaration(declaration)) { + const initializer = declaration.getInitializer(); + return extractValueFromExpression(initializer); + } + + // Handle export assignments (export default "value") + if (Node.isExportAssignment(declaration)) { + const expression = declaration.getExpression(); + return extractValueFromExpression(expression); + } + + // Handle named exports (export { x }) + if (Node.isExportSpecifier(declaration)) { + // Find the local symbol this specifier refers to + const name = declaration.getNameNode().getText(); + const sourceFile = declaration.getSourceFile(); + + // Find the local declaration with this name + for (const varDecl of sourceFile.getDescendantsOfKind(SyntaxKind.VariableDeclaration)) { + if (varDecl.getName() === name) { + const initializer = varDecl.getInitializer(); + return extractValueFromExpression(initializer); + } + } + } + + return undefined; +} + +/** + * Extract value from an expression node + */ +function extractValueFromExpression(expression: Node | undefined): { value: string; isLiteral: boolean } | undefined { + if (!expression) { + return undefined; + } + + if (Node.isStringLiteral(expression)) { + return { + value: expression.getLiteralValue(), + isLiteral: true, + }; + } + + if (Node.isTemplateExpression(expression)) { + return { + value: expression.getText(), + isLiteral: false, + }; + } + + if (Node.isNoSubstitutionTemplateLiteral(expression)) { + return { + value: expression.getLiteralValue(), + isLiteral: true, + }; + } + + return undefined; +} + +/** + * Process string tokens in imported values + */ +export function processImportedStringTokens( + importedValues: Map, + propertyName: string, + value: string, + path: string[] = [], + TOKEN_REGEX: RegExp, +): TokenReference[] { + const tokens: TokenReference[] = []; + + // Check if the value is an imported value reference + if (importedValues.has(value)) { + const importedValue = importedValues.get(value)!; + + if (importedValue.isLiteral) { + // Process the imported literal for token references + const matches = importedValue.value.match(TOKEN_REGEX); + + if (matches) { + matches.forEach(match => { + tokens.push({ + property: propertyName, + token: match, + path, + isVariableReference: true, + sourceFile: importedValue.sourceFile, + }); + }); + } + } + } + + return tokens; +} From e1a324d97e3d14360ff866e3fafaf325220e3f50 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 12 Mar 2025 15:50:28 -0700 Subject: [PATCH 06/24] remove console log. --- .../library/src/__tests__/typeCheckerImports.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/react-components/token-analyzer-preview/library/src/__tests__/typeCheckerImports.test.ts b/packages/react-components/token-analyzer-preview/library/src/__tests__/typeCheckerImports.test.ts index 9c5ab0e0f4c57..890ae2621b1b9 100644 --- a/packages/react-components/token-analyzer-preview/library/src/__tests__/typeCheckerImports.test.ts +++ b/packages/react-components/token-analyzer-preview/library/src/__tests__/typeCheckerImports.test.ts @@ -123,7 +123,6 @@ describe('Type Checker Import Analysis', () => { project.addSourceFilesAtPaths([path.join(TEST_DIR, '**/*.ts')]); const importedValues: Map = await analyzeImports(sourceFile, project); - console.log(importedValues); // Verify standard re-export (Component) expect(importedValues.has('Component')).toBe(true); From 404bba018666975bad9a8c643fc878efbcb9c7aa Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 14 Mar 2025 17:24:12 -0700 Subject: [PATCH 07/24] adding comment updating readme --- .../react-components/token-analyzer-preview/library/README.md | 4 ++-- .../token-analyzer-preview/library/src/astAnalyzer.ts | 2 ++ 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/react-components/token-analyzer-preview/library/README.md b/packages/react-components/token-analyzer-preview/library/README.md index 8240ad4066a4b..d540ac7b92ff2 100644 --- a/packages/react-components/token-analyzer-preview/library/README.md +++ b/packages/react-components/token-analyzer-preview/library/README.md @@ -6,7 +6,7 @@ A static analysis tool that scans your project's style files to track and analyz - ~~Some property assignments can also be function calls, we need to process this scenario~~ - ~~`createCustomFocusIndicatorStyle` is a special function that is used throughout the library so we might be able to special case it~~ -- if we have file imports we need to analyze those such as importing base styles +- ~~if we have file imports we need to analyze those such as importing base styles~~ - we also need to ensure var analysis is done correctly after the refactor ~~- Manage makeResetStyles (likely same as makeStyles)~~ - Button has some weird patterns in it where it uses makeResetStyles and then uses enums to pull in the styles, we might need to account for those as well. @@ -16,7 +16,7 @@ A static analysis tool that scans your project's style files to track and analyz - ~~if we have functions we can't process (or other code for that matter), can we add that data into our report so we know to manually go deal with it?~~ - ~~assignedSlots in output to track which slots classes are applied to~~ - ~~Add variables full name to metadata (i.e. classNames.icon instead of just 'icon)~~ -- Module importing +- ~~Module importing~~ - Look at the path info again. Do we ever need it? ## Features diff --git a/packages/react-components/token-analyzer-preview/library/src/astAnalyzer.ts b/packages/react-components/token-analyzer-preview/library/src/astAnalyzer.ts index fc6a1b4ee8332..022ef540b0891 100644 --- a/packages/react-components/token-analyzer-preview/library/src/astAnalyzer.ts +++ b/packages/react-components/token-analyzer-preview/library/src/astAnalyzer.ts @@ -108,6 +108,8 @@ function processStyleProperty( } }); } else if (Node.isCallExpression(node) && node.getExpression().getText() === 'createCustomFocusIndicatorStyle') { + // Special handling for createCustomFocusIndicatorStyle + // We can expand this to other functions as needed const focus = `:focus`; const focusWithin = `:focus-within`; let nestedModifier = focus; From aced75513cb514a4d6d79c37571f69f12b51f2a0 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 20 Mar 2025 15:20:11 -0700 Subject: [PATCH 08/24] writing css var analysis tests and ensure failure --- .../library/src/__tests__/cssVarE2E.test.ts | 313 ++++++++++++++++++ 1 file changed, 313 insertions(+) create mode 100644 packages/react-components/token-analyzer-preview/library/src/__tests__/cssVarE2E.test.ts diff --git a/packages/react-components/token-analyzer-preview/library/src/__tests__/cssVarE2E.test.ts b/packages/react-components/token-analyzer-preview/library/src/__tests__/cssVarE2E.test.ts new file mode 100644 index 0000000000000..6689c28cb5bd8 --- /dev/null +++ b/packages/react-components/token-analyzer-preview/library/src/__tests__/cssVarE2E.test.ts @@ -0,0 +1,313 @@ +// cssVarE2E.test.ts +import { Project } from 'ts-morph'; +import { analyzeFile } from '../astAnalyzer.js'; +import * as path from 'path'; +import * as fs from 'fs/promises'; + +// Test file contents +const cssVarsStyleFile = ` +import { makeStyles } from '@griffel/react'; +import { tokens } from '@fluentui/react-theme'; +import { colorPrimary, colorSecondary, nestedFallbackVar, complexCssVar } from './tokenVars'; + +const useStyles = makeStyles({ + // Direct token reference + direct: { + color: tokens.colorNeutralForeground1, + }, + // CSS variable with token + cssVar: { + color: \`var(--theme-color, \${tokens.colorBrandForeground1})\`, + }, + // Imported direct token + importedToken: { + color: colorPrimary, + }, + // Imported CSS variable with token + importedCssVar: { + color: colorSecondary, + }, + // Nested CSS variable with token + nestedCssVar: { + color: \`var(--primary, var(--secondary, \${tokens.colorBrandForeground2}))\`, + }, + // Imported nested CSS variable with token + importedNestedVar: { + color: nestedFallbackVar, + }, + // Imported complex CSS variable with multiple tokens + importedComplexVar: { + color: complexCssVar, + }, +}); +`; + +const tokenVarsFile = ` +import { tokens } from '@fluentui/react-theme'; +// Direct token exports +export const colorPrimary = tokens.colorBrandForeground1; +export const colorSecondary = \`var(--color, \${tokens.colorBrandForeground2})\`; + +// Nested fallback vars +export const nestedFallbackVar = \`var(--a, var(--b, \${tokens.colorNeutralForeground3}))\`; + +// Complex vars with multiple tokens +export const complexCssVar = \`var(--complex, var(--nested, \${tokens.colorBrandBackground})) var(--another, \${tokens.colorNeutralBackground1})\`; +`; + +describe('CSS Variable Token Extraction E2E', () => { + let project: Project; + let tempDir: string; + let stylesFilePath: string; + let varsFilePath: string; + + beforeAll(async () => { + // Create temp directory for test files + tempDir = path.join(__dirname, 'temp-e2e-test'); + await fs.mkdir(tempDir, { recursive: true }); + + // Create test files + stylesFilePath = path.join(tempDir, 'test.styles.ts'); + varsFilePath = path.join(tempDir, 'tokenVars.ts'); + + await fs.writeFile(stylesFilePath, cssVarsStyleFile); + await fs.writeFile(varsFilePath, tokenVarsFile); + + // Initialize project + project = new Project({ + tsConfigFilePath: path.join(tempDir, '../../../tsconfig.json'), + skipAddingFilesFromTsConfig: true, + }); + }); + + afterAll(async () => { + // Clean up temp files + // await fs.rm(tempDir, { recursive: true, force: true }); + }); + + test('analyzes and extracts all token references from CSS variables', async () => { + // Run the analyzer on our test files + const analysis = await analyzeFile(stylesFilePath, project); + + // Verify the overall structure + expect(analysis).toHaveProperty('styles'); + expect(analysis).toHaveProperty('metadata'); + + const { styles } = analysis; + expect(styles).toHaveProperty('useStyles'); + + console.log(styles); + + const useStyles = styles.useStyles; + + // 1. Verify direct token reference + expect(useStyles.direct.tokens).toContainEqual( + expect.objectContaining({ + property: 'color', + token: 'tokens.colorNeutralForeground1', + }), + ); + + // 2. Verify CSS variable with token + expect(useStyles.cssVar.tokens).toContainEqual( + expect.objectContaining({ + property: 'color', + token: 'tokens.colorBrandForeground1', + }), + ); + + // 3. Verify imported direct token + expect(useStyles.importedToken.tokens).toContainEqual( + expect.objectContaining({ + property: 'color', + token: 'tokens.colorBrandForeground1', + isVariableReference: true, + }), + ); + + // 4. Verify imported CSS variable with token + expect(useStyles.importedCssVar.tokens).toContainEqual( + expect.objectContaining({ + property: 'color', + token: 'tokens.colorBrandForeground2', + isVariableReference: true, + }), + ); + + // 5. Verify nested CSS variable with token + expect(useStyles.nestedCssVar.tokens).toContainEqual( + expect.objectContaining({ + property: 'color', + token: 'tokens.colorBrandForeground3', + }), + ); + + // 6. Verify imported nested CSS variable with token + expect(useStyles.importedNestedVar.tokens).toContainEqual( + expect.objectContaining({ + property: 'color', + token: 'tokens.colorNeutralForeground3', + isVariableReference: true, + }), + ); + + // 7. Verify complex CSS variable with multiple tokens + const complexVarTokens = useStyles.complexVar.tokens; + expect(complexVarTokens).toHaveLength(2); + expect(complexVarTokens).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + token: 'tokens.colorNeutralForeground4', + }), + expect.objectContaining({ + token: 'tokens.colorBrandForeground4', + }), + ]), + ); + + // 8. Verify imported complex CSS variable with multiple tokens + const importedComplexVarTokens = useStyles.importedComplexVar.tokens; + expect(importedComplexVarTokens.length).toBeGreaterThan(1); + expect(importedComplexVarTokens).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + token: 'tokens.colorBrandBackground', + isVariableReference: true, + }), + expect.objectContaining({ + token: 'tokens.colorNeutralBackground', + isVariableReference: true, + }), + ]), + ); + }); +}); + +// This test focuses on the full end-to-end integration of the CSS variable extraction +// with the module resolution system +describe('CSS Variable Cross-Module Resolution E2E', () => { + let project: Project; + let tempDir: string; + + beforeAll(async () => { + // Create temp directory and subdirectories for test files + tempDir = path.join(__dirname, 'temp-cross-module-test'); + const varsDir = path.join(tempDir, 'tokens'); + const stylesDir = path.join(tempDir, 'styles'); + + await fs.mkdir(varsDir, { recursive: true }); + await fs.mkdir(stylesDir, { recursive: true }); + + // Create a deeper structure to test cross-module resolution + await fs.writeFile( + path.join(varsDir, 'colors.ts'), + ` + // Base token definitions + export const primaryToken = 'tokens.colorBrandPrimary'; + export const secondaryToken = 'tokens.colorBrandSecondary'; + `, + ); + + await fs.writeFile( + path.join(varsDir, 'variables.ts'), + ` + import { primaryToken, secondaryToken } from './colors'; + + // CSS Variables referencing tokens + export const primaryVar = \`var(--primary, \${primaryToken})\`; + export const nestedVar = \`var(--nested, var(--fallback, \${secondaryToken}))\`; + export const multiTokenVar = \`var(--multi, \${primaryToken} \${secondaryToken})\`; + `, + ); + + await fs.writeFile( + path.join(varsDir, 'index.ts'), + ` + // Re-export everything + export * from './colors'; + export * from './variables'; + `, + ); + + await fs.writeFile( + path.join(stylesDir, 'component.ts'), + ` + import { makeStyles } from '@griffel/react'; + import { primaryToken, primaryVar, nestedVar, multiTokenVar } from '../variables'; + + const useStyles = makeStyles({ + root: { + // Direct import + color: primaryToken, + // CSS var import + backgroundColor: primaryVar, + // Nested CSS var import + borderColor: nestedVar, + // Complex var with multiple tokens + padding: multiTokenVar, + } + }); + + export default useStyles; + `, + ); + + // Initialize project + project = new Project({ + tsConfigFilePath: path.join(tempDir, '../../../tsconfig.json'), + skipAddingFilesFromTsConfig: true, + }); + }); + + afterAll(async () => { + // Clean up temp files + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + test('resolves token references across module boundaries with CSS vars', async () => { + // Run the analyzer on the component styles file + const componentPath = path.join(tempDir, 'styles', 'component.ts'); + const analysis = await analyzeFile(componentPath, project); + + const { styles } = analysis; + expect(styles).toHaveProperty('useStyles'); + + const useStyles = styles.useStyles; + const rootStyle = useStyles.root; + + // Verify tokens were extracted from all import types + expect(rootStyle.tokens).toEqual( + expect.arrayContaining([ + // Direct import of token + expect.objectContaining({ + property: 'color', + token: 'tokens.colorBrandPrimary', + isVariableReference: true, + }), + // Import of CSS var with token + expect.objectContaining({ + property: 'backgroundColor', + token: 'tokens.colorBrandPrimary', + isVariableReference: true, + }), + // Import of nested CSS var with token + expect.objectContaining({ + property: 'borderColor', + token: 'tokens.colorBrandSecondary', + isVariableReference: true, + }), + // Multiple tokens from a complex var + expect.objectContaining({ + property: 'padding', + token: 'tokens.colorBrandPrimary', + isVariableReference: true, + }), + expect.objectContaining({ + property: 'padding', + token: 'tokens.colorBrandSecondary', + isVariableReference: true, + }), + ]), + ); + }); +}); From 42c5a720ee6877333c41f4db26fb250fe1774623 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 20 Mar 2025 15:21:10 -0700 Subject: [PATCH 09/24] uncomment clean up --- .../library/src/__tests__/cssVarE2E.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-components/token-analyzer-preview/library/src/__tests__/cssVarE2E.test.ts b/packages/react-components/token-analyzer-preview/library/src/__tests__/cssVarE2E.test.ts index 6689c28cb5bd8..6ef0d5aa1585c 100644 --- a/packages/react-components/token-analyzer-preview/library/src/__tests__/cssVarE2E.test.ts +++ b/packages/react-components/token-analyzer-preview/library/src/__tests__/cssVarE2E.test.ts @@ -82,7 +82,7 @@ describe('CSS Variable Token Extraction E2E', () => { afterAll(async () => { // Clean up temp files - // await fs.rm(tempDir, { recursive: true, force: true }); + await fs.rm(tempDir, { recursive: true, force: true }); }); test('analyzes and extracts all token references from CSS variables', async () => { From 6c89604be04d558e78274607bd287c0207cbf85c Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 21 Mar 2025 16:43:50 -0700 Subject: [PATCH 10/24] update readme --- .../react-components/token-analyzer-preview/library/README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/react-components/token-analyzer-preview/library/README.md b/packages/react-components/token-analyzer-preview/library/README.md index d540ac7b92ff2..61d696d43d225 100644 --- a/packages/react-components/token-analyzer-preview/library/README.md +++ b/packages/react-components/token-analyzer-preview/library/README.md @@ -18,6 +18,7 @@ A static analysis tool that scans your project's style files to track and analyz - ~~Add variables full name to metadata (i.e. classNames.icon instead of just 'icon)~~ - ~~Module importing~~ - Look at the path info again. Do we ever need it? +- Convert token member within the analysis output to an array so we can hold multiple tokens. The order should be the order or priority. [0] being the highest pri with the last item in the array the least prioritized. ## Features From e8cd1d61ecb5d8075dcc029d22bcf87018ff87ff Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 21 Mar 2025 16:45:20 -0700 Subject: [PATCH 11/24] adding ability to analyze css vars updating some broken import analysis --- .../library/src/__tests__/cssVarE2E.test.ts | 1 + .../library/src/astAnalyzer.ts | 27 +++++- .../library/src/cssVarTokenExtractor.ts | 83 +++++++++++++++++++ .../library/src/importAnalyzer.ts | 22 ++++- 4 files changed, 126 insertions(+), 7 deletions(-) create mode 100644 packages/react-components/token-analyzer-preview/library/src/cssVarTokenExtractor.ts diff --git a/packages/react-components/token-analyzer-preview/library/src/__tests__/cssVarE2E.test.ts b/packages/react-components/token-analyzer-preview/library/src/__tests__/cssVarE2E.test.ts index 6ef0d5aa1585c..8b4744ab5a8f8 100644 --- a/packages/react-components/token-analyzer-preview/library/src/__tests__/cssVarE2E.test.ts +++ b/packages/react-components/token-analyzer-preview/library/src/__tests__/cssVarE2E.test.ts @@ -117,6 +117,7 @@ describe('CSS Variable Token Extraction E2E', () => { ); // 3. Verify imported direct token + console.log(useStyles.importedToken.tokens); expect(useStyles.importedToken.tokens).toContainEqual( expect.objectContaining({ property: 'color', diff --git a/packages/react-components/token-analyzer-preview/library/src/astAnalyzer.ts b/packages/react-components/token-analyzer-preview/library/src/astAnalyzer.ts index 022ef540b0891..fe48fbdc510f9 100644 --- a/packages/react-components/token-analyzer-preview/library/src/astAnalyzer.ts +++ b/packages/react-components/token-analyzer-preview/library/src/astAnalyzer.ts @@ -12,6 +12,7 @@ import { } from './types.js'; import { log, measure, measureAsync } from './debugUtils.js'; import { analyzeImports, processImportedStringTokens, ImportedValue } from './importAnalyzer.js'; +import { extractTokensFromCssVars } from './cssVarTokenExtractor'; const makeResetStylesToken = 'resetStyles'; @@ -53,8 +54,11 @@ function processStyleProperty( path.push(parentName); } - if (Node.isStringLiteral(node)) { + // Check for string literals or template expressions (string template literals) + if (Node.isStringLiteral(node) || Node.isTemplateExpression(node)) { const text = node.getText().replace(/['"]/g, ''); // Remove quotes + + // Check for direct token references const matches = text.match(TOKEN_REGEX); if (matches) { matches.forEach(match => { @@ -65,6 +69,12 @@ function processStyleProperty( }); }); } + + // Check for CSS var() syntax that might contain tokens + if (text.includes('var(')) { + const cssVarTokens = extractTokensFromCssVars(text, path[path.length - 1] || parentName, path, TOKEN_REGEX); + tokens.push(...cssVarTokens); + } } else if (Node.isIdentifier(node)) { const text = node.getText(); @@ -108,8 +118,6 @@ function processStyleProperty( } }); } else if (Node.isCallExpression(node) && node.getExpression().getText() === 'createCustomFocusIndicatorStyle') { - // Special handling for createCustomFocusIndicatorStyle - // We can expand this to other functions as needed const focus = `:focus`; const focusWithin = `:focus-within`; let nestedModifier = focus; @@ -153,6 +161,19 @@ function processStyleProperty( } }); } + // Check for string literals in function arguments that might contain CSS variables with tokens + if (Node.isStringLiteral(argument)) { + const text = argument.getText().replace(/['"]/g, ''); + if (text.includes('var(')) { + const cssVarTokens = extractTokensFromCssVars( + text, + path[path.length - 1] || parentName, + [...path, functionName], + TOKEN_REGEX, + ); + tokens.push(...cssVarTokens); + } + } }); } } diff --git a/packages/react-components/token-analyzer-preview/library/src/cssVarTokenExtractor.ts b/packages/react-components/token-analyzer-preview/library/src/cssVarTokenExtractor.ts new file mode 100644 index 0000000000000..10d6040ef7a48 --- /dev/null +++ b/packages/react-components/token-analyzer-preview/library/src/cssVarTokenExtractor.ts @@ -0,0 +1,83 @@ +// cssVarTokenExtractor.ts +import { log } from './debugUtils.js'; +import { TokenReference } from './types.js'; + +/** + * Extracts token references from CSS variable syntax including nested fallback chains + * Example: var(--some-token, var(--fallback, var(${tokens.someToken}))) + * + * @param value The CSS variable string to process + * @param propertyName The CSS property name this value is assigned to + * @param path The path in the style object + * @param TOKEN_REGEX The regex pattern to match token references + * @returns Array of token references found in the string + */ +export function extractTokensFromCssVars( + value: string, + propertyName: string, + path: string[] = [], + TOKEN_REGEX: RegExp, +): TokenReference[] { + const tokens: TokenReference[] = []; + + // Direct token matches in the string + const directMatches = value.match(TOKEN_REGEX); + if (directMatches) { + directMatches.forEach(match => { + tokens.push({ + property: propertyName, + token: match, + path, + }); + }); + } + + // Look for CSS var() patterns + const varPattern = /var\s*\(\s*([^,)]*),?\s*(.*?)\s*\)/g; + let match: RegExpExecArray | null; + + while ((match = varPattern.exec(value)) !== null) { + const fullMatch = match[0]; // The entire var(...) expression + const varName = match[1]; // The CSS variable name + const fallback = match[2]; // The fallback value, which might contain nested var() calls + + log(`Processing CSS var: ${fullMatch}`); + log(` - Variable name: ${varName}`); + log(` - Fallback: ${fallback}`); + + // Check if the variable name contains a token reference + const varNameTokens = varName.match(TOKEN_REGEX); + if (varNameTokens) { + varNameTokens.forEach(token => { + tokens.push({ + property: propertyName, + token, + path, + }); + }); + } + + // If there's a fallback value, it might contain tokens or nested var() calls + if (fallback) { + // Recursively process the fallback + if (fallback.includes('var(')) { + const fallbackTokens = extractTokensFromCssVars(fallback, propertyName, path, TOKEN_REGEX); + tokens.push(...fallbackTokens); + } else { + // Check for direct token references in the fallback + const fallbackTokens = fallback.match(TOKEN_REGEX); + if (fallbackTokens) { + fallbackTokens.forEach(token => { + tokens.push({ + property: propertyName, + token, + path, + }); + }); + } + } + } + } + + return tokens; +} diff --git a/packages/react-components/token-analyzer-preview/library/src/importAnalyzer.ts b/packages/react-components/token-analyzer-preview/library/src/importAnalyzer.ts index 4d2d7c03847d0..81ef674f54474 100644 --- a/packages/react-components/token-analyzer-preview/library/src/importAnalyzer.ts +++ b/packages/react-components/token-analyzer-preview/library/src/importAnalyzer.ts @@ -3,6 +3,7 @@ import { Project, Node, SourceFile, ImportDeclaration, Symbol, TypeChecker, Synt import { log } from './debugUtils.js'; import { TokenReference } from './types.js'; import { getModuleSourceFile } from './moduleResolver.js'; +import { extractTokensFromCssVars } from './cssVarTokenExtractor.js'; /** * Represents a value imported from another module @@ -263,10 +264,10 @@ function extractValueFromExpression(expression: Node | undefined): { value: stri }; } - if (Node.isTemplateExpression(expression)) { + if (Node.isTemplateExpression(expression) || Node.isPropertyAccessExpression(expression)) { return { value: expression.getText(), - isLiteral: false, + isLiteral: Node.isTemplateExpression(expression), }; } @@ -297,9 +298,8 @@ export function processImportedStringTokens( const importedValue = importedValues.get(value)!; if (importedValue.isLiteral) { - // Process the imported literal for token references + // First, check for direct token references const matches = importedValue.value.match(TOKEN_REGEX); - if (matches) { matches.forEach(match => { tokens.push({ @@ -311,6 +311,20 @@ export function processImportedStringTokens( }); }); } + + // Then check for CSS variable patterns that might contain tokens + if (importedValue.value.includes('var(')) { + const cssVarTokens = extractTokensFromCssVars(importedValue.value, propertyName, path, TOKEN_REGEX); + + // Add CSS variable tokens with the source information + cssVarTokens.forEach(token => { + tokens.push({ + ...token, + isVariableReference: true, + sourceFile: importedValue.sourceFile, + }); + }); + } } } From 3f98951c6645505d8d0a20249524528c620436d7 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 21 Mar 2025 22:00:47 -0700 Subject: [PATCH 12/24] fixing import processing for string literals and property accessors --- .../library/src/__tests__/cssVarE2E.test.ts | 2 +- .../library/src/importAnalyzer.ts | 43 ++++++++++++++----- 2 files changed, 34 insertions(+), 11 deletions(-) diff --git a/packages/react-components/token-analyzer-preview/library/src/__tests__/cssVarE2E.test.ts b/packages/react-components/token-analyzer-preview/library/src/__tests__/cssVarE2E.test.ts index 8b4744ab5a8f8..a8f32f420519b 100644 --- a/packages/react-components/token-analyzer-preview/library/src/__tests__/cssVarE2E.test.ts +++ b/packages/react-components/token-analyzer-preview/library/src/__tests__/cssVarE2E.test.ts @@ -29,7 +29,7 @@ const useStyles = makeStyles({ }, // Nested CSS variable with token nestedCssVar: { - color: \`var(--primary, var(--secondary, \${tokens.colorBrandForeground2}))\`, + background: \`var(--primary, var(--secondary, \${tokens.colorBrandForeground2}))\`, }, // Imported nested CSS variable with token importedNestedVar: { diff --git a/packages/react-components/token-analyzer-preview/library/src/importAnalyzer.ts b/packages/react-components/token-analyzer-preview/library/src/importAnalyzer.ts index 81ef674f54474..033e37b3bafed 100644 --- a/packages/react-components/token-analyzer-preview/library/src/importAnalyzer.ts +++ b/packages/react-components/token-analyzer-preview/library/src/importAnalyzer.ts @@ -262,16 +262,12 @@ function extractValueFromExpression(expression: Node | undefined): { value: stri value: expression.getLiteralValue(), isLiteral: true, }; - } - - if (Node.isTemplateExpression(expression) || Node.isPropertyAccessExpression(expression)) { + } else if (Node.isTemplateExpression(expression) || Node.isPropertyAccessExpression(expression)) { return { value: expression.getText(), isLiteral: Node.isTemplateExpression(expression), }; - } - - if (Node.isNoSubstitutionTemplateLiteral(expression)) { + } else if (Node.isNoSubstitutionTemplateLiteral(expression)) { return { value: expression.getLiteralValue(), isLiteral: true, @@ -298,6 +294,9 @@ export function processImportedStringTokens( const importedValue = importedValues.get(value)!; if (importedValue.isLiteral) { + console.log(`Processing literal value: ${importedValue.value}`); + // Process literal values (strings and template literals) + // First, check for direct token references const matches = importedValue.value.match(TOKEN_REGEX); if (matches) { @@ -310,10 +309,8 @@ export function processImportedStringTokens( sourceFile: importedValue.sourceFile, }); }); - } - - // Then check for CSS variable patterns that might contain tokens - if (importedValue.value.includes('var(')) { + } else if (importedValue.value.includes('var(')) { + // Then check for CSS variable patterns that might contain tokens const cssVarTokens = extractTokensFromCssVars(importedValue.value, propertyName, path, TOKEN_REGEX); // Add CSS variable tokens with the source information @@ -325,6 +322,32 @@ export function processImportedStringTokens( }); }); } + } else { + // Process non-literal values (property access expressions, etc.) + + // Check if the value directly matches the token pattern (tokens.someToken) + const matches = importedValue.value.match(TOKEN_REGEX); + if (importedValue.value.match(TOKEN_REGEX)) { + tokens.push({ + property: propertyName, + token: importedValue.value, + path, + isVariableReference: true, + sourceFile: importedValue.sourceFile, + }); + } else if (matches) { + // For template expressions, we might need to extract tokens from parts of the expression + // This is a simplified approach - might need enhancement for complex template expressions + matches.forEach(match => { + tokens.push({ + property: propertyName, + token: match, + path, + isVariableReference: true, + sourceFile: importedValue.sourceFile, + }); + }); + } } } From f3f981a7e7c0c9002f4a7a7212d04d2f3caddccb Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Tue, 25 Mar 2025 02:56:09 -0700 Subject: [PATCH 13/24] Fix duplication issues in token data adjust tests so we aren't testing for scenarios we can't cover add todo for more complexity/nesting --- .../token-analyzer-preview/library/README.md | 1 + .../library/src/__tests__/cssVarE2E.test.ts | 68 ++++++++----------- .../library/src/astAnalyzer.ts | 24 +++---- .../library/src/cssVarTokenExtractor.ts | 10 ++- .../library/src/importAnalyzer.ts | 1 - 5 files changed, 50 insertions(+), 54 deletions(-) diff --git a/packages/react-components/token-analyzer-preview/library/README.md b/packages/react-components/token-analyzer-preview/library/README.md index 61d696d43d225..d14f853878dfe 100644 --- a/packages/react-components/token-analyzer-preview/library/README.md +++ b/packages/react-components/token-analyzer-preview/library/README.md @@ -19,6 +19,7 @@ A static analysis tool that scans your project's style files to track and analyz - ~~Module importing~~ - Look at the path info again. Do we ever need it? - Convert token member within the analysis output to an array so we can hold multiple tokens. The order should be the order or priority. [0] being the highest pri with the last item in the array the least prioritized. +- lowpri: Handle very complex cases like `var(--optional-token, var(--semantic-token, ${some-other-var-with-a-string-or-fallback}))`. This other var might be in another package or file as well. Currently we won't handle this level of depth but we could do symbol extraction in the future if needed to resolve the chain fully. This will likely require changes in importAnalyzer.ts and structural changes in the data we return ## Features diff --git a/packages/react-components/token-analyzer-preview/library/src/__tests__/cssVarE2E.test.ts b/packages/react-components/token-analyzer-preview/library/src/__tests__/cssVarE2E.test.ts index a8f32f420519b..bd1a936963ffd 100644 --- a/packages/react-components/token-analyzer-preview/library/src/__tests__/cssVarE2E.test.ts +++ b/packages/react-components/token-analyzer-preview/library/src/__tests__/cssVarE2E.test.ts @@ -17,7 +17,7 @@ const useStyles = makeStyles({ }, // CSS variable with token cssVar: { - color: \`var(--theme-color, \${tokens.colorBrandForeground1})\`, + color: \`var(--theme-color, \${tokens.colorBrandForeground4})\`, }, // Imported direct token importedToken: { @@ -45,8 +45,8 @@ const useStyles = makeStyles({ const tokenVarsFile = ` import { tokens } from '@fluentui/react-theme'; // Direct token exports -export const colorPrimary = tokens.colorBrandForeground1; -export const colorSecondary = \`var(--color, \${tokens.colorBrandForeground2})\`; +export const colorPrimary = tokens.colorBrandForeground6; +export const colorSecondary = \`var(--color, \${tokens.colorBrandForeground3})\`; // Nested fallback vars export const nestedFallbackVar = \`var(--a, var(--b, \${tokens.colorNeutralForeground3}))\`; @@ -96,11 +96,10 @@ describe('CSS Variable Token Extraction E2E', () => { const { styles } = analysis; expect(styles).toHaveProperty('useStyles'); - console.log(styles); - 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', @@ -109,41 +108,45 @@ describe('CSS Variable Token Extraction E2E', () => { ); // 2. Verify CSS variable with token + expect(useStyles.cssVar.tokens.length).toBe(1); expect(useStyles.cssVar.tokens).toContainEqual( expect.objectContaining({ property: 'color', - token: 'tokens.colorBrandForeground1', + token: 'tokens.colorBrandForeground4', }), ); // 3. Verify imported direct token - console.log(useStyles.importedToken.tokens); + expect(useStyles.importedToken.tokens.length).toBe(1); expect(useStyles.importedToken.tokens).toContainEqual( expect.objectContaining({ property: 'color', - token: 'tokens.colorBrandForeground1', + token: 'tokens.colorBrandForeground6', isVariableReference: true, }), ); // 4. Verify imported CSS variable with token + expect(useStyles.importedCssVar.tokens.length).toBe(1); expect(useStyles.importedCssVar.tokens).toContainEqual( expect.objectContaining({ property: 'color', - token: 'tokens.colorBrandForeground2', + token: 'tokens.colorBrandForeground3', isVariableReference: true, }), ); // 5. Verify nested CSS variable with token + expect(useStyles.nestedCssVar.tokens.length).toBe(1); expect(useStyles.nestedCssVar.tokens).toContainEqual( expect.objectContaining({ - property: 'color', - token: 'tokens.colorBrandForeground3', + property: 'background', + token: 'tokens.colorBrandForeground2', }), ); // 6. Verify imported nested CSS variable with token + expect(useStyles.importedNestedVar.tokens.length).toBe(1); expect(useStyles.importedNestedVar.tokens).toContainEqual( expect.objectContaining({ property: 'color', @@ -152,31 +155,16 @@ describe('CSS Variable Token Extraction E2E', () => { }), ); - // 7. Verify complex CSS variable with multiple tokens - const complexVarTokens = useStyles.complexVar.tokens; - expect(complexVarTokens).toHaveLength(2); - expect(complexVarTokens).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - token: 'tokens.colorNeutralForeground4', - }), - expect.objectContaining({ - token: 'tokens.colorBrandForeground4', - }), - ]), - ); - // 8. Verify imported complex CSS variable with multiple tokens - const importedComplexVarTokens = useStyles.importedComplexVar.tokens; - expect(importedComplexVarTokens.length).toBeGreaterThan(1); - expect(importedComplexVarTokens).toEqual( + expect(useStyles.importedComplexVar.tokens.length).toBe(2); + expect(useStyles.importedComplexVar.tokens).toEqual( expect.arrayContaining([ expect.objectContaining({ token: 'tokens.colorBrandBackground', isVariableReference: true, }), expect.objectContaining({ - token: 'tokens.colorNeutralBackground', + token: 'tokens.colorNeutralBackground1', isVariableReference: true, }), ]), @@ -203,9 +191,10 @@ describe('CSS Variable Cross-Module Resolution E2E', () => { 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 primaryToken = tokens.colorBrandPrimary; + export const secondaryToken = tokens.colorBrandSecondary; `, ); @@ -213,11 +202,12 @@ describe('CSS Variable Cross-Module Resolution E2E', () => { path.join(varsDir, 'variables.ts'), ` import { primaryToken, secondaryToken } from './colors'; + import { tokens } from '@fluentui/react-theme'; // CSS Variables referencing tokens - export const primaryVar = \`var(--primary, \${primaryToken})\`; - export const nestedVar = \`var(--nested, var(--fallback, \${secondaryToken}))\`; - export const multiTokenVar = \`var(--multi, \${primaryToken} \${secondaryToken})\`; + export const primaryVar = \`var(--primary, \${tokens.colorBrandPrimary})\`; + export const nestedVar = \`var(--nested, var(--fallback, \${tokens.colorBrandSecondary}))\`; + export const multiTokenVar = \`var(--multi, \${tokens.colorBrandPrimary} \${tokens.colorBrandSecondary})\`; `, ); @@ -231,10 +221,10 @@ describe('CSS Variable Cross-Module Resolution E2E', () => { ); await fs.writeFile( - path.join(stylesDir, 'component.ts'), + path.join(stylesDir, 'component.styles.ts'), ` import { makeStyles } from '@griffel/react'; - import { primaryToken, primaryVar, nestedVar, multiTokenVar } from '../variables'; + import { primaryToken, primaryVar, nestedVar, multiTokenVar } from '../tokens'; const useStyles = makeStyles({ root: { @@ -243,7 +233,7 @@ describe('CSS Variable Cross-Module Resolution E2E', () => { // CSS var import backgroundColor: primaryVar, // Nested CSS var import - borderColor: nestedVar, + border: nestedVar, // Complex var with multiple tokens padding: multiTokenVar, } @@ -267,7 +257,7 @@ describe('CSS Variable Cross-Module Resolution E2E', () => { 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.ts'); + const componentPath = path.join(tempDir, 'styles', 'component.styles.ts'); const analysis = await analyzeFile(componentPath, project); const { styles } = analysis; @@ -293,7 +283,7 @@ describe('CSS Variable Cross-Module Resolution E2E', () => { }), // Import of nested CSS var with token expect.objectContaining({ - property: 'borderColor', + property: 'border', token: 'tokens.colorBrandSecondary', isVariableReference: true, }), diff --git a/packages/react-components/token-analyzer-preview/library/src/astAnalyzer.ts b/packages/react-components/token-analyzer-preview/library/src/astAnalyzer.ts index fe48fbdc510f9..7bc0eb4d8f803 100644 --- a/packages/react-components/token-analyzer-preview/library/src/astAnalyzer.ts +++ b/packages/react-components/token-analyzer-preview/library/src/astAnalyzer.ts @@ -58,22 +58,22 @@ function processStyleProperty( if (Node.isStringLiteral(node) || Node.isTemplateExpression(node)) { const text = node.getText().replace(/['"]/g, ''); // Remove quotes - // Check for direct token references - const matches = text.match(TOKEN_REGEX); - if (matches) { - matches.forEach(match => { - tokens.push({ - property: path[path.length - 1] || parentName, - token: match, - path, - }); - }); - } - // Check for CSS var() syntax that might contain tokens if (text.includes('var(')) { const cssVarTokens = extractTokensFromCssVars(text, path[path.length - 1] || parentName, path, TOKEN_REGEX); tokens.push(...cssVarTokens); + } else { + // Check for direct token references + const matches = text.match(TOKEN_REGEX); + if (matches) { + matches.forEach(match => { + tokens.push({ + property: path[path.length - 1] || parentName, + token: match, + path, + }); + }); + } } } else if (Node.isIdentifier(node)) { const text = node.getText(); diff --git a/packages/react-components/token-analyzer-preview/library/src/cssVarTokenExtractor.ts b/packages/react-components/token-analyzer-preview/library/src/cssVarTokenExtractor.ts index 10d6040ef7a48..abadf6c6d7ee2 100644 --- a/packages/react-components/token-analyzer-preview/library/src/cssVarTokenExtractor.ts +++ b/packages/react-components/token-analyzer-preview/library/src/cssVarTokenExtractor.ts @@ -20,10 +20,13 @@ export function extractTokensFromCssVars( ): TokenReference[] { const tokens: TokenReference[] = []; + let testValue = value; + // Direct token matches in the string - const directMatches = value.match(TOKEN_REGEX); + const directMatches = testValue.match(TOKEN_REGEX); if (directMatches) { directMatches.forEach(match => { + testValue = testValue.replace(match, ''); // Remove direct matches from the string tokens.push({ property: propertyName, token: match, @@ -32,11 +35,14 @@ export function extractTokensFromCssVars( }); } + // we have an issue with duplicated calls. A direct match will match the whole string as would a token within a var part + // found by the regex, so we need to remove the direct matches from the string + // Look for CSS var() patterns const varPattern = /var\s*\(\s*([^,)]*),?\s*(.*?)\s*\)/g; let match: RegExpExecArray | null; - while ((match = varPattern.exec(value)) !== null) { + while ((match = varPattern.exec(testValue)) !== null) { const fullMatch = match[0]; // The entire var(...) expression const varName = match[1]; // The CSS variable name const fallback = match[2]; // The fallback value, which might contain nested var() calls diff --git a/packages/react-components/token-analyzer-preview/library/src/importAnalyzer.ts b/packages/react-components/token-analyzer-preview/library/src/importAnalyzer.ts index 033e37b3bafed..533382e34a731 100644 --- a/packages/react-components/token-analyzer-preview/library/src/importAnalyzer.ts +++ b/packages/react-components/token-analyzer-preview/library/src/importAnalyzer.ts @@ -294,7 +294,6 @@ export function processImportedStringTokens( const importedValue = importedValues.get(value)!; if (importedValue.isLiteral) { - console.log(`Processing literal value: ${importedValue.value}`); // Process literal values (strings and template literals) // First, check for direct token references From c745219b244d177ba99d5cbe32ca459ccf2e2e33 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Tue, 25 Mar 2025 13:29:46 -0700 Subject: [PATCH 14/24] updating button analysis fixing import for cjs --- .../library/analysis.json | 717 ++++++------------ .../library/src/astAnalyzer.ts | 2 +- 2 files changed, 237 insertions(+), 482 deletions(-) diff --git a/packages/react-components/token-analyzer-preview/library/analysis.json b/packages/react-components/token-analyzer-preview/library/analysis.json index d19dd9bcd388d..1c9285ae440ac 100644 --- a/packages/react-components/token-analyzer-preview/library/analysis.json +++ b/packages/react-components/token-analyzer-preview/library/analysis.json @@ -7,65 +7,62 @@ { "property": "backgroundColor", "token": "tokens.colorNeutralBackground1", - "path": [ - "backgroundColor" - ] + "path": ["backgroundColor"] }, { "property": "color", "token": "tokens.colorNeutralForeground1", - "path": [ - "color" - ] + "path": ["color"] + }, + { + "property": "border", + "token": "tokens.strokeWidthThin", + "path": ["border"] + }, + { + "property": "border", + "token": "tokens.colorNeutralStroke1", + "path": ["border"] }, { "property": "fontFamily", "token": "tokens.fontFamilyBase", - "path": [ - "fontFamily" - ] + "path": ["fontFamily"] + }, + { + "property": "padding", + "token": "tokens.spacingHorizontalM", + "path": ["padding"] }, { "property": "borderRadius", "token": "tokens.borderRadiusMedium", - "path": [ - "borderRadius" - ] + "path": ["borderRadius"] }, { "property": "fontSize", "token": "tokens.fontSizeBase300", - "path": [ - "fontSize" - ] + "path": ["fontSize"] }, { "property": "fontWeight", "token": "tokens.fontWeightSemibold", - "path": [ - "fontWeight" - ] + "path": ["fontWeight"] }, { "property": "lineHeight", "token": "tokens.lineHeightBase300", - "path": [ - "lineHeight" - ] + "path": ["lineHeight"] }, { "property": "transitionDuration", "token": "tokens.durationFaster", - "path": [ - "transitionDuration" - ] + "path": ["transitionDuration"] }, { "property": "transitionTimingFunction", "token": "tokens.curveEasyEase", - "path": [ - "transitionTimingFunction" - ] + "path": ["transitionTimingFunction"] } ], "nested": { @@ -109,9 +106,7 @@ } }, "isResetStyles": true, - "assignedVariables": [ - "rootBaseClassName" - ] + "assignedVariables": ["rootBaseClassName"] } }, "useIconBaseClassName": { @@ -120,16 +115,12 @@ { "property": "[iconSpacingVar]", "token": "tokens.spacingHorizontalSNudge", - "path": [ - "[iconSpacingVar]" - ] + "path": ["[iconSpacingVar]"] } ], "nested": {}, "isResetStyles": true, - "assignedVariables": [ - "iconBaseClassName" - ] + "assignedVariables": ["iconBaseClassName"] } }, "useRootStyles": { @@ -138,9 +129,7 @@ { "property": "backgroundColor", "token": "tokens.colorTransparentBackground", - "path": [ - "backgroundColor" - ] + "path": ["backgroundColor"] } ], "nested": { @@ -163,25 +152,19 @@ ] } }, - "assignedVariables": [ - "rootStyles" - ] + "assignedVariables": ["rootStyles"] }, "primary": { "tokens": [ { "property": "backgroundColor", "token": "tokens.colorBrandBackground", - "path": [ - "backgroundColor" - ] + "path": ["backgroundColor"] }, { "property": "color", "token": "tokens.colorNeutralForegroundOnBrand", - "path": [ - "color" - ] + "path": ["color"] } ], "nested": { @@ -214,25 +197,19 @@ ] } }, - "assignedVariables": [ - "rootStyles" - ] + "assignedVariables": ["rootStyles"] }, "subtle": { "tokens": [ { "property": "backgroundColor", "token": "tokens.colorSubtleBackground", - "path": [ - "backgroundColor" - ] + "path": ["backgroundColor"] }, { "property": "color", "token": "tokens.colorNeutralForeground2", - "path": [ - "color" - ] + "path": ["color"] } ], "nested": { @@ -275,25 +252,19 @@ ] } }, - "assignedVariables": [ - "rootStyles" - ] + "assignedVariables": ["rootStyles"] }, "transparent": { "tokens": [ { "property": "backgroundColor", "token": "tokens.colorTransparentBackground", - "path": [ - "backgroundColor" - ] + "path": ["backgroundColor"] }, { "property": "color", "token": "tokens.colorNeutralForeground2", - "path": [ - "color" - ] + "path": ["color"] } ], "nested": { @@ -340,107 +311,87 @@ ] } }, - "assignedVariables": [ - "rootStyles" - ] + "assignedVariables": ["rootStyles"] }, "circular": { "tokens": [ { "property": "borderRadius", "token": "tokens.borderRadiusCircular", - "path": [ - "borderRadius" - ] + "path": ["borderRadius"] } ], - "assignedVariables": [ - "rootStyles" - ] + "assignedVariables": ["rootStyles"] }, "square": { "tokens": [ { "property": "borderRadius", "token": "tokens.borderRadiusNone", - "path": [ - "borderRadius" - ] + "path": ["borderRadius"] } ], - "assignedVariables": [ - "rootStyles" - ] + "assignedVariables": ["rootStyles"] }, "small": { "tokens": [ + { + "property": "padding", + "token": "tokens.spacingHorizontalS", + "path": ["padding"] + }, { "property": "borderRadius", "token": "tokens.borderRadiusMedium", - "path": [ - "borderRadius" - ] + "path": ["borderRadius"] }, { "property": "fontSize", "token": "tokens.fontSizeBase200", - "path": [ - "fontSize" - ] + "path": ["fontSize"] }, { "property": "fontWeight", "token": "tokens.fontWeightRegular", - "path": [ - "fontWeight" - ] + "path": ["fontWeight"] }, { "property": "lineHeight", "token": "tokens.lineHeightBase200", - "path": [ - "lineHeight" - ] + "path": ["lineHeight"] } ], - "assignedVariables": [ - "rootStyles" - ] + "assignedVariables": ["rootStyles"] }, "large": { "tokens": [ + { + "property": "padding", + "token": "tokens.spacingHorizontalL", + "path": ["padding"] + }, { "property": "borderRadius", "token": "tokens.borderRadiusMedium", - "path": [ - "borderRadius" - ] + "path": ["borderRadius"] }, { "property": "fontSize", "token": "tokens.fontSizeBase400", - "path": [ - "fontSize" - ] + "path": ["fontSize"] }, { "property": "fontWeight", "token": "tokens.fontWeightSemibold", - "path": [ - "fontWeight" - ] + "path": ["fontWeight"] }, { "property": "lineHeight", "token": "tokens.lineHeightBase400", - "path": [ - "lineHeight" - ] + "path": ["lineHeight"] } ], - "assignedVariables": [ - "rootStyles" - ] + "assignedVariables": ["rootStyles"] } }, "useRootDisabledStyles": { @@ -449,16 +400,12 @@ { "property": "backgroundColor", "token": "tokens.colorNeutralBackgroundDisabled", - "path": [ - "backgroundColor" - ] + "path": ["backgroundColor"] }, { "property": "color", "token": "tokens.colorNeutralForegroundDisabled", - "path": [ - "color" - ] + "path": ["color"] } ], "nested": { @@ -510,18 +457,14 @@ ] } }, - "assignedVariables": [ - "rootDisabledStyles" - ] + "assignedVariables": ["rootDisabledStyles"] }, "outline": { "tokens": [ { "property": "backgroundColor", "token": "tokens.colorTransparentBackground", - "path": [ - "backgroundColor" - ] + "path": ["backgroundColor"] } ], "nested": { @@ -544,18 +487,14 @@ ] } }, - "assignedVariables": [ - "rootDisabledStyles" - ] + "assignedVariables": ["rootDisabledStyles"] }, "subtle": { "tokens": [ { "property": "backgroundColor", "token": "tokens.colorTransparentBackground", - "path": [ - "backgroundColor" - ] + "path": ["backgroundColor"] } ], "nested": { @@ -578,18 +517,14 @@ ] } }, - "assignedVariables": [ - "rootDisabledStyles" - ] + "assignedVariables": ["rootDisabledStyles"] }, "transparent": { "tokens": [ { "property": "backgroundColor", "token": "tokens.colorTransparentBackground", - "path": [ - "backgroundColor" - ] + "path": ["backgroundColor"] } ], "nested": { @@ -612,9 +547,7 @@ ] } }, - "assignedVariables": [ - "rootDisabledStyles" - ] + "assignedVariables": ["rootDisabledStyles"] } }, "useRootFocusStyles": { @@ -631,9 +564,7 @@ ] } }, - "assignedVariables": [ - "rootFocusStyles" - ] + "assignedVariables": ["rootFocusStyles"] }, "square": { "tokens": [], @@ -648,9 +579,7 @@ ] } }, - "assignedVariables": [ - "rootFocusStyles" - ] + "assignedVariables": ["rootFocusStyles"] }, "small": { "tokens": [], @@ -665,9 +594,7 @@ ] } }, - "assignedVariables": [ - "rootFocusStyles" - ] + "assignedVariables": ["rootFocusStyles"] }, "large": { "tokens": [], @@ -682,9 +609,7 @@ ] } }, - "assignedVariables": [ - "rootFocusStyles" - ] + "assignedVariables": ["rootFocusStyles"] } }, "useRootIconOnlyStyles": {}, @@ -694,28 +619,20 @@ { "property": "[iconSpacingVar]", "token": "tokens.spacingHorizontalXS", - "path": [ - "[iconSpacingVar]" - ] + "path": ["[iconSpacingVar]"] } ], - "assignedVariables": [ - "iconStyles" - ] + "assignedVariables": ["iconStyles"] }, "large": { "tokens": [ { "property": "[iconSpacingVar]", "token": "tokens.spacingHorizontalSNudge", - "path": [ - "[iconSpacingVar]" - ] + "path": ["[iconSpacingVar]"] } ], - "assignedVariables": [ - "iconStyles" - ] + "assignedVariables": ["iconStyles"] } } }, @@ -750,33 +667,23 @@ "slotName": "root" }, "rootStyles.smallWithIcon": { - "conditions": [ - "icon && size === 'small'" - ], + "conditions": ["icon && size === 'small'"], "slotName": "root" }, "rootStyles.largeWithIcon": { - "conditions": [ - "icon && size === 'large'" - ], + "conditions": ["icon && size === 'large'"], "slotName": "root" }, "rootDisabledStyles.base": { - "conditions": [ - "(disabled || disabledFocusable)" - ], + "conditions": ["(disabled || disabledFocusable)"], "slotName": "root" }, "rootDisabledStyles.highContrast": { - "conditions": [ - "(disabled || disabledFocusable)" - ], + "conditions": ["(disabled || disabledFocusable)"], "slotName": "root" }, "rootFocusStyles.primary": { - "conditions": [ - "appearance === 'primary'" - ], + "conditions": ["appearance === 'primary'"], "slotName": "root" }, "buttonClassNames.icon": { @@ -806,91 +713,65 @@ { "property": "color", "token": "tokens.colorNeutralForeground1Selected", - "path": [ - "color" - ] + "path": ["color"] } ], - "assignedVariables": [ - "rootExpandedStyles" - ] + "assignedVariables": ["rootExpandedStyles"] }, "primary": { "tokens": [ { "property": "backgroundColor", "token": "tokens.colorBrandBackgroundSelected", - "path": [ - "backgroundColor" - ] + "path": ["backgroundColor"] } ], - "assignedVariables": [ - "rootExpandedStyles" - ] + "assignedVariables": ["rootExpandedStyles"] }, "secondary": { "tokens": [ { "property": "backgroundColor", "token": "tokens.colorNeutralBackground1Selected", - "path": [ - "backgroundColor" - ] + "path": ["backgroundColor"] }, { "property": "color", "token": "tokens.colorNeutralForeground1Selected", - "path": [ - "color" - ] + "path": ["color"] } ], - "assignedVariables": [ - "rootExpandedStyles" - ] + "assignedVariables": ["rootExpandedStyles"] }, "subtle": { "tokens": [ { "property": "backgroundColor", "token": "tokens.colorSubtleBackgroundSelected", - "path": [ - "backgroundColor" - ] + "path": ["backgroundColor"] }, { "property": "color", "token": "tokens.colorNeutralForeground2Selected", - "path": [ - "color" - ] + "path": ["color"] } ], - "assignedVariables": [ - "rootExpandedStyles" - ] + "assignedVariables": ["rootExpandedStyles"] }, "transparent": { "tokens": [ { "property": "backgroundColor", "token": "tokens.colorTransparentBackgroundSelected", - "path": [ - "backgroundColor" - ] + "path": ["backgroundColor"] }, { "property": "color", "token": "tokens.colorNeutralForeground2BrandSelected", - "path": [ - "color" - ] + "path": ["color"] } ], - "assignedVariables": [ - "rootExpandedStyles" - ] + "assignedVariables": ["rootExpandedStyles"] } }, "useIconExpandedStyles": { @@ -899,56 +780,40 @@ { "property": "color", "token": "tokens.colorNeutralForeground1Selected", - "path": [ - "color" - ] + "path": ["color"] } ], - "assignedVariables": [ - "iconExpandedStyles" - ] + "assignedVariables": ["iconExpandedStyles"] }, "secondary": { "tokens": [ { "property": "color", "token": "tokens.colorNeutralForeground1Selected", - "path": [ - "color" - ] + "path": ["color"] } ], - "assignedVariables": [ - "iconExpandedStyles" - ] + "assignedVariables": ["iconExpandedStyles"] }, "subtle": { "tokens": [ { "property": "color", "token": "tokens.colorNeutralForeground2BrandSelected", - "path": [ - "color" - ] + "path": ["color"] } ], - "assignedVariables": [ - "iconExpandedStyles" - ] + "assignedVariables": ["iconExpandedStyles"] }, "transparent": { "tokens": [ { "property": "color", "token": "tokens.colorNeutralForeground2BrandSelected", - "path": [ - "color" - ] + "path": ["color"] } ], - "assignedVariables": [ - "iconExpandedStyles" - ] + "assignedVariables": ["iconExpandedStyles"] } }, "useMenuIconStyles": { @@ -957,56 +822,40 @@ { "property": "lineHeight", "token": "tokens.lineHeightBase200", - "path": [ - "lineHeight" - ] + "path": ["lineHeight"] } ], - "assignedVariables": [ - "menuIconStyles" - ] + "assignedVariables": ["menuIconStyles"] }, "medium": { "tokens": [ { "property": "lineHeight", "token": "tokens.lineHeightBase200", - "path": [ - "lineHeight" - ] + "path": ["lineHeight"] } ], - "assignedVariables": [ - "menuIconStyles" - ] + "assignedVariables": ["menuIconStyles"] }, "large": { "tokens": [ { "property": "lineHeight", "token": "tokens.lineHeightBase400", - "path": [ - "lineHeight" - ] + "path": ["lineHeight"] } ], - "assignedVariables": [ - "menuIconStyles" - ] + "assignedVariables": ["menuIconStyles"] }, "notIconOnly": { "tokens": [ { "property": "marginLeft", "token": "tokens.spacingHorizontalXS", - "path": [ - "marginLeft" - ] + "path": ["marginLeft"] } ], - "assignedVariables": [ - "menuIconStyles" - ] + "assignedVariables": ["menuIconStyles"] } } }, @@ -1021,9 +870,7 @@ "slotName": "root" }, "rootExpandedStyles.base": { - "conditions": [ - "state.root['aria-expanded']" - ], + "conditions": ["state.root['aria-expanded']"], "slotName": "root" }, "menuButtonClassNames.icon": { @@ -1035,9 +882,7 @@ "slotName": "icon" }, "iconExpandedStyles.highContrast": { - "conditions": [ - "state.root['aria-expanded'] && iconExpandedStyles[state.appearance]" - ], + "conditions": ["state.root['aria-expanded'] && iconExpandedStyles[state.appearance]"], "slotName": "icon" }, "menuButtonClassNames.menuIcon": { @@ -1053,9 +898,7 @@ "slotName": "menuIcon" }, "menuIconStyles.notIconOnly": { - "conditions": [ - "!state.iconOnly" - ], + "conditions": ["!state.iconOnly"], "slotName": "menuIcon" } } @@ -1095,9 +938,7 @@ ] } }, - "assignedVariables": [ - "rootStyles" - ] + "assignedVariables": ["rootStyles"] }, "primary": { "tokens": [], @@ -1130,9 +971,7 @@ ] } }, - "assignedVariables": [ - "rootStyles" - ] + "assignedVariables": ["rootStyles"] }, "subtle": { "tokens": [], @@ -1165,9 +1004,7 @@ ] } }, - "assignedVariables": [ - "rootStyles" - ] + "assignedVariables": ["rootStyles"] }, "transparent": { "tokens": [], @@ -1200,72 +1037,102 @@ ] } }, - "assignedVariables": [ - "rootStyles" - ] + "assignedVariables": ["rootStyles"] }, "small": { "tokens": [ + { + "property": "padding", + "token": "tokens.spacingHorizontalS", + "path": ["padding"] + }, + { + "property": "padding", + "token": "tokens.spacingHorizontalS", + "path": ["padding"] + }, + { + "property": "padding", + "token": "tokens.spacingHorizontalMNudge", + "path": ["padding"] + }, + { + "property": "padding", + "token": "tokens.spacingHorizontalS", + "path": ["padding"] + }, { "property": "fontSize", "token": "tokens.fontSizeBase300", - "path": [ - "fontSize" - ] + "path": ["fontSize"] }, { "property": "lineHeight", "token": "tokens.lineHeightBase300", - "path": [ - "lineHeight" - ] + "path": ["lineHeight"] } ], - "assignedVariables": [ - "rootStyles" - ] + "assignedVariables": ["rootStyles"] }, "medium": { "tokens": [ + { + "property": "padding", + "token": "tokens.spacingHorizontalM", + "path": ["padding"] + }, + { + "property": "padding", + "token": "tokens.spacingHorizontalL", + "path": ["padding"] + }, + { + "property": "padding", + "token": "tokens.spacingHorizontalM", + "path": ["padding"] + }, { "property": "fontSize", "token": "tokens.fontSizeBase300", - "path": [ - "fontSize" - ] + "path": ["fontSize"] }, { "property": "lineHeight", "token": "tokens.lineHeightBase300", - "path": [ - "lineHeight" - ] + "path": ["lineHeight"] } ], - "assignedVariables": [ - "rootStyles" - ] + "assignedVariables": ["rootStyles"] }, "large": { "tokens": [ + { + "property": "padding", + "token": "tokens.spacingHorizontalL", + "path": ["padding"] + }, + { + "property": "padding", + "token": "tokens.spacingHorizontalXL", + "path": ["padding"] + }, + { + "property": "padding", + "token": "tokens.spacingHorizontalL", + "path": ["padding"] + }, { "property": "fontSize", "token": "tokens.fontSizeBase400", - "path": [ - "fontSize" - ] + "path": ["fontSize"] }, { "property": "lineHeight", "token": "tokens.lineHeightBase400", - "path": [ - "lineHeight" - ] + "path": ["lineHeight"] } ], - "assignedVariables": [ - "rootStyles" - ] + "assignedVariables": ["rootStyles"] }, "disabled": { "tokens": [], @@ -1298,9 +1165,7 @@ ] } }, - "assignedVariables": [ - "rootStyles" - ] + "assignedVariables": ["rootStyles"] } }, "useRootIconOnlyStyles": { @@ -1309,42 +1174,30 @@ { "property": "padding", "token": "tokens.spacingHorizontalXS", - "path": [ - "padding" - ] + "path": ["padding"] } ], - "assignedVariables": [ - "rootIconOnlyStyles" - ] + "assignedVariables": ["rootIconOnlyStyles"] }, "medium": { "tokens": [ { "property": "padding", "token": "tokens.spacingHorizontalSNudge", - "path": [ - "padding" - ] + "path": ["padding"] } ], - "assignedVariables": [ - "rootIconOnlyStyles" - ] + "assignedVariables": ["rootIconOnlyStyles"] }, "large": { "tokens": [ { "property": "padding", "token": "tokens.spacingHorizontalS", - "path": [ - "padding" - ] + "path": ["padding"] } ], - "assignedVariables": [ - "rootIconOnlyStyles" - ] + "assignedVariables": ["rootIconOnlyStyles"] } }, "useIconStyles": { @@ -1353,28 +1206,20 @@ { "property": "marginRight", "token": "tokens.spacingHorizontalM", - "path": [ - "marginRight" - ] + "path": ["marginRight"] } ], - "assignedVariables": [ - "iconStyles" - ] + "assignedVariables": ["iconStyles"] }, "after": { "tokens": [ { "property": "marginLeft", "token": "tokens.spacingHorizontalM", - "path": [ - "marginLeft" - ] + "path": ["marginLeft"] } ], - "assignedVariables": [ - "iconStyles" - ] + "assignedVariables": ["iconStyles"] } }, "useContentContainerStyles": {}, @@ -1384,56 +1229,40 @@ { "property": "fontWeight", "token": "tokens.fontWeightRegular", - "path": [ - "fontWeight" - ] + "path": ["fontWeight"] } ], - "assignedVariables": [ - "secondaryContentStyles" - ] + "assignedVariables": ["secondaryContentStyles"] }, "small": { "tokens": [ { "property": "fontSize", "token": "tokens.fontSizeBase200", - "path": [ - "fontSize" - ] + "path": ["fontSize"] } ], - "assignedVariables": [ - "secondaryContentStyles" - ] + "assignedVariables": ["secondaryContentStyles"] }, "medium": { "tokens": [ { "property": "fontSize", "token": "tokens.fontSizeBase200", - "path": [ - "fontSize" - ] + "path": ["fontSize"] } ], - "assignedVariables": [ - "secondaryContentStyles" - ] + "assignedVariables": ["secondaryContentStyles"] }, "large": { "tokens": [ { "property": "fontSize", "token": "tokens.fontSizeBase300", - "path": [ - "fontSize" - ] + "path": ["fontSize"] } ], - "assignedVariables": [ - "secondaryContentStyles" - ] + "assignedVariables": ["secondaryContentStyles"] } } }, @@ -1460,15 +1289,11 @@ "slotName": "root" }, "rootStyles.disabled": { - "conditions": [ - "(disabled || disabledFocusable)" - ], + "conditions": ["(disabled || disabledFocusable)"], "slotName": "root" }, "rootStyles.disabledHighContrast": { - "conditions": [ - "(disabled || disabledFocusable)" - ], + "conditions": ["(disabled || disabledFocusable)"], "slotName": "root" }, "compoundButtonClassNames.contentContainer": { @@ -1549,9 +1374,7 @@ ] } }, - "assignedVariables": [ - "rootStyles" - ] + "assignedVariables": ["rootStyles"] }, "subtle": { "tokens": [], @@ -1584,9 +1407,7 @@ ] } }, - "assignedVariables": [ - "rootStyles" - ] + "assignedVariables": ["rootStyles"] }, "transparent": { "tokens": [], @@ -1619,9 +1440,7 @@ ] } }, - "assignedVariables": [ - "rootStyles" - ] + "assignedVariables": ["rootStyles"] }, "disabled": { "tokens": [], @@ -1654,9 +1473,7 @@ ] } }, - "assignedVariables": [ - "rootStyles" - ] + "assignedVariables": ["rootStyles"] } } }, @@ -1675,15 +1492,11 @@ "slotName": "root" }, "rootStyles.disabled": { - "conditions": [ - "(disabled || disabledFocusable)" - ], + "conditions": ["(disabled || disabledFocusable)"], "slotName": "root" }, "rootStyles.disabledHighContrast": { - "conditions": [ - "(disabled || disabledFocusable)" - ], + "conditions": ["(disabled || disabledFocusable)"], "slotName": "root" }, "splitButtonClassNames.menuButton": { @@ -1721,16 +1534,12 @@ { "property": "backgroundColor", "token": "tokens.colorNeutralBackground1Selected", - "path": [ - "backgroundColor" - ] + "path": ["backgroundColor"] }, { "property": "color", "token": "tokens.colorNeutralForeground1Selected", - "path": [ - "color" - ] + "path": ["color"] } ], "nested": { @@ -1763,18 +1572,14 @@ ] } }, - "assignedVariables": [ - "rootCheckedStyles" - ] + "assignedVariables": ["rootCheckedStyles"] }, "outline": { "tokens": [ { "property": "backgroundColor", "token": "tokens.colorTransparentBackgroundSelected", - "path": [ - "backgroundColor" - ] + "path": ["backgroundColor"] } ], "nested": { @@ -1797,25 +1602,19 @@ ] } }, - "assignedVariables": [ - "rootCheckedStyles" - ] + "assignedVariables": ["rootCheckedStyles"] }, "primary": { "tokens": [ { "property": "backgroundColor", "token": "tokens.colorBrandBackgroundSelected", - "path": [ - "backgroundColor" - ] + "path": ["backgroundColor"] }, { "property": "color", "token": "tokens.colorNeutralForegroundOnBrand", - "path": [ - "color" - ] + "path": ["color"] } ], "nested": { @@ -1848,25 +1647,19 @@ ] } }, - "assignedVariables": [ - "rootCheckedStyles" - ] + "assignedVariables": ["rootCheckedStyles"] }, "subtle": { "tokens": [ { "property": "backgroundColor", "token": "tokens.colorSubtleBackgroundSelected", - "path": [ - "backgroundColor" - ] + "path": ["backgroundColor"] }, { "property": "color", "token": "tokens.colorNeutralForeground2Selected", - "path": [ - "color" - ] + "path": ["color"] } ], "nested": { @@ -1899,25 +1692,19 @@ ] } }, - "assignedVariables": [ - "rootCheckedStyles" - ] + "assignedVariables": ["rootCheckedStyles"] }, "transparent": { "tokens": [ { "property": "backgroundColor", "token": "tokens.colorTransparentBackgroundSelected", - "path": [ - "backgroundColor" - ] + "path": ["backgroundColor"] }, { "property": "color", "token": "tokens.colorNeutralForeground2BrandSelected", - "path": [ - "color" - ] + "path": ["color"] } ], "nested": { @@ -1950,9 +1737,7 @@ ] } }, - "assignedVariables": [ - "rootCheckedStyles" - ] + "assignedVariables": ["rootCheckedStyles"] } }, "useRootDisabledStyles": { @@ -1961,16 +1746,12 @@ { "property": "backgroundColor", "token": "tokens.colorNeutralBackgroundDisabled", - "path": [ - "backgroundColor" - ] + "path": ["backgroundColor"] }, { "property": "color", "token": "tokens.colorNeutralForegroundDisabled", - "path": [ - "color" - ] + "path": ["color"] } ], "nested": { @@ -2003,18 +1784,14 @@ ] } }, - "assignedVariables": [ - "rootDisabledStyles" - ] + "assignedVariables": ["rootDisabledStyles"] }, "subtle": { "tokens": [ { "property": "backgroundColor", "token": "tokens.colorTransparentBackground", - "path": [ - "backgroundColor" - ] + "path": ["backgroundColor"] } ], "nested": { @@ -2037,18 +1814,14 @@ ] } }, - "assignedVariables": [ - "rootDisabledStyles" - ] + "assignedVariables": ["rootDisabledStyles"] }, "transparent": { "tokens": [ { "property": "backgroundColor", "token": "tokens.colorTransparentBackground", - "path": [ - "backgroundColor" - ] + "path": ["backgroundColor"] } ], "nested": { @@ -2071,9 +1844,7 @@ ] } }, - "assignedVariables": [ - "rootDisabledStyles" - ] + "assignedVariables": ["rootDisabledStyles"] } }, "useIconCheckedStyles": { @@ -2082,14 +1853,10 @@ { "property": "color", "token": "tokens.colorNeutralForeground2BrandSelected", - "path": [ - "color" - ] + "path": ["color"] } ], - "assignedVariables": [ - "iconCheckedStyles" - ] + "assignedVariables": ["iconCheckedStyles"] } }, "usePrimaryHighContrastStyles": {} @@ -2105,33 +1872,23 @@ "slotName": "root" }, "primaryHighContrastStyles.base": { - "conditions": [ - "appearance === 'primary'" - ], + "conditions": ["appearance === 'primary'"], "slotName": "root" }, "primaryHighContrastStyles.disabled": { - "conditions": [ - "appearance === 'primary' && (disabled || disabledFocusable)" - ], + "conditions": ["appearance === 'primary' && (disabled || disabledFocusable)"], "slotName": "root" }, "rootCheckedStyles.base": { - "conditions": [ - "checked" - ], + "conditions": ["checked"], "slotName": "root" }, "rootCheckedStyles.highContrast": { - "conditions": [ - "checked" - ], + "conditions": ["checked"], "slotName": "root" }, "rootDisabledStyles.base": { - "conditions": [ - "(disabled || disabledFocusable)" - ], + "conditions": ["(disabled || disabledFocusable)"], "slotName": "root" }, "toggleButtonClassNames.icon": { @@ -2147,12 +1904,10 @@ "slotName": "icon" }, "iconCheckedStyles.subtleOrTransparent": { - "conditions": [ - "checked && (appearance === 'subtle' || appearance === 'transparent')" - ], + "conditions": ["checked && (appearance === 'subtle' || appearance === 'transparent')"], "slotName": "icon" } } } } -} \ No newline at end of file +} diff --git a/packages/react-components/token-analyzer-preview/library/src/astAnalyzer.ts b/packages/react-components/token-analyzer-preview/library/src/astAnalyzer.ts index 7bc0eb4d8f803..02476ca06d2fc 100644 --- a/packages/react-components/token-analyzer-preview/library/src/astAnalyzer.ts +++ b/packages/react-components/token-analyzer-preview/library/src/astAnalyzer.ts @@ -12,7 +12,7 @@ import { } from './types.js'; import { log, measure, measureAsync } from './debugUtils.js'; import { analyzeImports, processImportedStringTokens, ImportedValue } from './importAnalyzer.js'; -import { extractTokensFromCssVars } from './cssVarTokenExtractor'; +import { extractTokensFromCssVars } from './cssVarTokenExtractor.js'; const makeResetStylesToken = 'resetStyles'; From 999a716bfe97fad89aa0e64fe7b5502a0d40e1e0 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Tue, 25 Mar 2025 13:29:55 -0700 Subject: [PATCH 15/24] update README --- .../react-components/token-analyzer-preview/library/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-components/token-analyzer-preview/library/README.md b/packages/react-components/token-analyzer-preview/library/README.md index d14f853878dfe..7595650aa79cc 100644 --- a/packages/react-components/token-analyzer-preview/library/README.md +++ b/packages/react-components/token-analyzer-preview/library/README.md @@ -19,7 +19,7 @@ A static analysis tool that scans your project's style files to track and analyz - ~~Module importing~~ - Look at the path info again. Do we ever need it? - Convert token member within the analysis output to an array so we can hold multiple tokens. The order should be the order or priority. [0] being the highest pri with the last item in the array the least prioritized. -- lowpri: Handle very complex cases like `var(--optional-token, var(--semantic-token, ${some-other-var-with-a-string-or-fallback}))`. This other var might be in another package or file as well. Currently we won't handle this level of depth but we could do symbol extraction in the future if needed to resolve the chain fully. This will likely require changes in importAnalyzer.ts and structural changes in the data we return +- **This is high pri now since we have components in source using this technique (see buttonstyles.styles.ts)** Handle very complex cases like `var(--optional-token, var(--semantic-token, ${some-other-var-with-a-string-or-fallback}))`. This other var might be in another package or file as well. Currently we won't handle this level of depth but we could do symbol extraction in the future if needed to resolve the chain fully. This will likely require changes in importAnalyzer.ts and structural changes in the data we return. On top of needing to find referenced symbols within an aliased template string literal, we might also then need to parse out var fallbacks within short hands. IE: `padding: 'var(--first, var(--second)) 10px` and ensure the ordering is correct. ## Features From 35e3a378704f58c2188f51bfccfe13443c0326fe Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Tue, 25 Mar 2025 13:32:40 -0700 Subject: [PATCH 16/24] updating analysis with default format from tool and order --- .../library/analysis.json | 398 +++++++++--------- 1 file changed, 199 insertions(+), 199 deletions(-) diff --git a/packages/react-components/token-analyzer-preview/library/analysis.json b/packages/react-components/token-analyzer-preview/library/analysis.json index 1c9285ae440ac..af495c3d3c1d3 100644 --- a/packages/react-components/token-analyzer-preview/library/analysis.json +++ b/packages/react-components/token-analyzer-preview/library/analysis.json @@ -705,205 +705,6 @@ } } }, - "library/src/components/MenuButton/useMenuButtonStyles.styles.ts": { - "styles": { - "useRootExpandedStyles": { - "outline": { - "tokens": [ - { - "property": "color", - "token": "tokens.colorNeutralForeground1Selected", - "path": ["color"] - } - ], - "assignedVariables": ["rootExpandedStyles"] - }, - "primary": { - "tokens": [ - { - "property": "backgroundColor", - "token": "tokens.colorBrandBackgroundSelected", - "path": ["backgroundColor"] - } - ], - "assignedVariables": ["rootExpandedStyles"] - }, - "secondary": { - "tokens": [ - { - "property": "backgroundColor", - "token": "tokens.colorNeutralBackground1Selected", - "path": ["backgroundColor"] - }, - { - "property": "color", - "token": "tokens.colorNeutralForeground1Selected", - "path": ["color"] - } - ], - "assignedVariables": ["rootExpandedStyles"] - }, - "subtle": { - "tokens": [ - { - "property": "backgroundColor", - "token": "tokens.colorSubtleBackgroundSelected", - "path": ["backgroundColor"] - }, - { - "property": "color", - "token": "tokens.colorNeutralForeground2Selected", - "path": ["color"] - } - ], - "assignedVariables": ["rootExpandedStyles"] - }, - "transparent": { - "tokens": [ - { - "property": "backgroundColor", - "token": "tokens.colorTransparentBackgroundSelected", - "path": ["backgroundColor"] - }, - { - "property": "color", - "token": "tokens.colorNeutralForeground2BrandSelected", - "path": ["color"] - } - ], - "assignedVariables": ["rootExpandedStyles"] - } - }, - "useIconExpandedStyles": { - "outline": { - "tokens": [ - { - "property": "color", - "token": "tokens.colorNeutralForeground1Selected", - "path": ["color"] - } - ], - "assignedVariables": ["iconExpandedStyles"] - }, - "secondary": { - "tokens": [ - { - "property": "color", - "token": "tokens.colorNeutralForeground1Selected", - "path": ["color"] - } - ], - "assignedVariables": ["iconExpandedStyles"] - }, - "subtle": { - "tokens": [ - { - "property": "color", - "token": "tokens.colorNeutralForeground2BrandSelected", - "path": ["color"] - } - ], - "assignedVariables": ["iconExpandedStyles"] - }, - "transparent": { - "tokens": [ - { - "property": "color", - "token": "tokens.colorNeutralForeground2BrandSelected", - "path": ["color"] - } - ], - "assignedVariables": ["iconExpandedStyles"] - } - }, - "useMenuIconStyles": { - "small": { - "tokens": [ - { - "property": "lineHeight", - "token": "tokens.lineHeightBase200", - "path": ["lineHeight"] - } - ], - "assignedVariables": ["menuIconStyles"] - }, - "medium": { - "tokens": [ - { - "property": "lineHeight", - "token": "tokens.lineHeightBase200", - "path": ["lineHeight"] - } - ], - "assignedVariables": ["menuIconStyles"] - }, - "large": { - "tokens": [ - { - "property": "lineHeight", - "token": "tokens.lineHeightBase400", - "path": ["lineHeight"] - } - ], - "assignedVariables": ["menuIconStyles"] - }, - "notIconOnly": { - "tokens": [ - { - "property": "marginLeft", - "token": "tokens.spacingHorizontalXS", - "path": ["marginLeft"] - } - ], - "assignedVariables": ["menuIconStyles"] - } - } - }, - "metadata": { - "styleConditions": { - "menuButtonClassNames.root": { - "isBase": true, - "slotName": "root" - }, - "state.root.className": { - "isBase": true, - "slotName": "root" - }, - "rootExpandedStyles.base": { - "conditions": ["state.root['aria-expanded']"], - "slotName": "root" - }, - "menuButtonClassNames.icon": { - "isBase": true, - "slotName": "icon" - }, - "state.icon.className": { - "isBase": true, - "slotName": "icon" - }, - "iconExpandedStyles.highContrast": { - "conditions": ["state.root['aria-expanded'] && iconExpandedStyles[state.appearance]"], - "slotName": "icon" - }, - "menuButtonClassNames.menuIcon": { - "isBase": true, - "slotName": "menuIcon" - }, - "menuIconStyles.base": { - "isBase": true, - "slotName": "menuIcon" - }, - "state.menuIcon.className": { - "isBase": true, - "slotName": "menuIcon" - }, - "menuIconStyles.notIconOnly": { - "conditions": ["!state.iconOnly"], - "slotName": "menuIcon" - } - } - } - }, "library/src/components/CompoundButton/useCompoundButtonStyles.styles.ts": { "styles": { "useRootStyles": { @@ -1339,6 +1140,205 @@ } } }, + "library/src/components/MenuButton/useMenuButtonStyles.styles.ts": { + "styles": { + "useRootExpandedStyles": { + "outline": { + "tokens": [ + { + "property": "color", + "token": "tokens.colorNeutralForeground1Selected", + "path": ["color"] + } + ], + "assignedVariables": ["rootExpandedStyles"] + }, + "primary": { + "tokens": [ + { + "property": "backgroundColor", + "token": "tokens.colorBrandBackgroundSelected", + "path": ["backgroundColor"] + } + ], + "assignedVariables": ["rootExpandedStyles"] + }, + "secondary": { + "tokens": [ + { + "property": "backgroundColor", + "token": "tokens.colorNeutralBackground1Selected", + "path": ["backgroundColor"] + }, + { + "property": "color", + "token": "tokens.colorNeutralForeground1Selected", + "path": ["color"] + } + ], + "assignedVariables": ["rootExpandedStyles"] + }, + "subtle": { + "tokens": [ + { + "property": "backgroundColor", + "token": "tokens.colorSubtleBackgroundSelected", + "path": ["backgroundColor"] + }, + { + "property": "color", + "token": "tokens.colorNeutralForeground2Selected", + "path": ["color"] + } + ], + "assignedVariables": ["rootExpandedStyles"] + }, + "transparent": { + "tokens": [ + { + "property": "backgroundColor", + "token": "tokens.colorTransparentBackgroundSelected", + "path": ["backgroundColor"] + }, + { + "property": "color", + "token": "tokens.colorNeutralForeground2BrandSelected", + "path": ["color"] + } + ], + "assignedVariables": ["rootExpandedStyles"] + } + }, + "useIconExpandedStyles": { + "outline": { + "tokens": [ + { + "property": "color", + "token": "tokens.colorNeutralForeground1Selected", + "path": ["color"] + } + ], + "assignedVariables": ["iconExpandedStyles"] + }, + "secondary": { + "tokens": [ + { + "property": "color", + "token": "tokens.colorNeutralForeground1Selected", + "path": ["color"] + } + ], + "assignedVariables": ["iconExpandedStyles"] + }, + "subtle": { + "tokens": [ + { + "property": "color", + "token": "tokens.colorNeutralForeground2BrandSelected", + "path": ["color"] + } + ], + "assignedVariables": ["iconExpandedStyles"] + }, + "transparent": { + "tokens": [ + { + "property": "color", + "token": "tokens.colorNeutralForeground2BrandSelected", + "path": ["color"] + } + ], + "assignedVariables": ["iconExpandedStyles"] + } + }, + "useMenuIconStyles": { + "small": { + "tokens": [ + { + "property": "lineHeight", + "token": "tokens.lineHeightBase200", + "path": ["lineHeight"] + } + ], + "assignedVariables": ["menuIconStyles"] + }, + "medium": { + "tokens": [ + { + "property": "lineHeight", + "token": "tokens.lineHeightBase200", + "path": ["lineHeight"] + } + ], + "assignedVariables": ["menuIconStyles"] + }, + "large": { + "tokens": [ + { + "property": "lineHeight", + "token": "tokens.lineHeightBase400", + "path": ["lineHeight"] + } + ], + "assignedVariables": ["menuIconStyles"] + }, + "notIconOnly": { + "tokens": [ + { + "property": "marginLeft", + "token": "tokens.spacingHorizontalXS", + "path": ["marginLeft"] + } + ], + "assignedVariables": ["menuIconStyles"] + } + } + }, + "metadata": { + "styleConditions": { + "menuButtonClassNames.root": { + "isBase": true, + "slotName": "root" + }, + "state.root.className": { + "isBase": true, + "slotName": "root" + }, + "rootExpandedStyles.base": { + "conditions": ["state.root['aria-expanded']"], + "slotName": "root" + }, + "menuButtonClassNames.icon": { + "isBase": true, + "slotName": "icon" + }, + "state.icon.className": { + "isBase": true, + "slotName": "icon" + }, + "iconExpandedStyles.highContrast": { + "conditions": ["state.root['aria-expanded'] && iconExpandedStyles[state.appearance]"], + "slotName": "icon" + }, + "menuButtonClassNames.menuIcon": { + "isBase": true, + "slotName": "menuIcon" + }, + "menuIconStyles.base": { + "isBase": true, + "slotName": "menuIcon" + }, + "state.menuIcon.className": { + "isBase": true, + "slotName": "menuIcon" + }, + "menuIconStyles.notIconOnly": { + "conditions": ["!state.iconOnly"], + "slotName": "menuIcon" + } + } + } + }, "library/src/components/SplitButton/useSplitButtonStyles.styles.ts": { "styles": { "useFocusStyles": {}, From a46e61789533d058f2b1a8c9223ad6fef36bc5d5 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Tue, 25 Mar 2025 13:35:09 -0700 Subject: [PATCH 17/24] add format to do --- .../react-components/token-analyzer-preview/library/README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/react-components/token-analyzer-preview/library/README.md b/packages/react-components/token-analyzer-preview/library/README.md index 7595650aa79cc..9a6f35f75fba6 100644 --- a/packages/react-components/token-analyzer-preview/library/README.md +++ b/packages/react-components/token-analyzer-preview/library/README.md @@ -20,6 +20,7 @@ A static analysis tool that scans your project's style files to track and analyz - Look at the path info again. Do we ever need it? - Convert token member within the analysis output to an array so we can hold multiple tokens. The order should be the order or priority. [0] being the highest pri with the last item in the array the least prioritized. - **This is high pri now since we have components in source using this technique (see buttonstyles.styles.ts)** Handle very complex cases like `var(--optional-token, var(--semantic-token, ${some-other-var-with-a-string-or-fallback}))`. This other var might be in another package or file as well. Currently we won't handle this level of depth but we could do symbol extraction in the future if needed to resolve the chain fully. This will likely require changes in importAnalyzer.ts and structural changes in the data we return. On top of needing to find referenced symbols within an aliased template string literal, we might also then need to parse out var fallbacks within short hands. IE: `padding: 'var(--first, var(--second)) 10px` and ensure the ordering is correct. +- Format output with prettier when we save to ensure stage lint doesn't fail. ## Features From dd782ac49fc709d7d084f503807c537ab2226c52 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Tue, 25 Mar 2025 16:02:28 -0700 Subject: [PATCH 18/24] some debug comments break template expression handling out separately so we can deal with spans --- .../library/src/__tests__/cssVarE2E.test.ts | 13 ++++++++++--- .../library/src/astAnalyzer.ts | 2 ++ .../library/src/importAnalyzer.ts | 13 +++++++++++-- 3 files changed, 23 insertions(+), 5 deletions(-) diff --git a/packages/react-components/token-analyzer-preview/library/src/__tests__/cssVarE2E.test.ts b/packages/react-components/token-analyzer-preview/library/src/__tests__/cssVarE2E.test.ts index bd1a936963ffd..73826e72648ca 100644 --- a/packages/react-components/token-analyzer-preview/library/src/__tests__/cssVarE2E.test.ts +++ b/packages/react-components/token-analyzer-preview/library/src/__tests__/cssVarE2E.test.ts @@ -195,19 +195,22 @@ describe('CSS Variable Cross-Module Resolution E2E', () => { // 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 } from './colors'; + 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, \${tokens.colorBrandPrimary} \${tokens.colorBrandSecondary})\`; + export const multiTokenVar = \`var(--multi, \${primaryToken} \${tokens.colorBrandSecondary})\`; + export const someMargin = tokens.spacingHorizontalXXL; + export const someOtherMargin = furtherMargin; `, ); @@ -224,7 +227,7 @@ describe('CSS Variable Cross-Module Resolution E2E', () => { path.join(stylesDir, 'component.styles.ts'), ` import { makeStyles } from '@griffel/react'; - import { primaryToken, primaryVar, nestedVar, multiTokenVar } from '../tokens'; + import { primaryToken, primaryVar, nestedVar, multiTokenVar, someMargin, someOtherMargin } from '../tokens'; const useStyles = makeStyles({ root: { @@ -236,6 +239,10 @@ describe('CSS Variable Cross-Module Resolution E2E', () => { border: nestedVar, // Complex var with multiple tokens padding: multiTokenVar, + // aliased and imported CSS var + marginRight:someMargin, + // aliased and imported CSS var with another level of indirection + marginRight:someOtherMargin } }); diff --git a/packages/react-components/token-analyzer-preview/library/src/astAnalyzer.ts b/packages/react-components/token-analyzer-preview/library/src/astAnalyzer.ts index 02476ca06d2fc..78ad527bfadf5 100644 --- a/packages/react-components/token-analyzer-preview/library/src/astAnalyzer.ts +++ b/packages/react-components/token-analyzer-preview/library/src/astAnalyzer.ts @@ -431,6 +431,8 @@ async function analyzeFile(filePath: string, project: Project): Promise analyzeImports(sourceFile, project)); + console.log(importedValues); + // Second pass: Analyze mergeClasses const styleMappings = measure('analyze mergeClasses', () => analyzeMergeClasses(sourceFile)); diff --git a/packages/react-components/token-analyzer-preview/library/src/importAnalyzer.ts b/packages/react-components/token-analyzer-preview/library/src/importAnalyzer.ts index 533382e34a731..a45a7733bb936 100644 --- a/packages/react-components/token-analyzer-preview/library/src/importAnalyzer.ts +++ b/packages/react-components/token-analyzer-preview/library/src/importAnalyzer.ts @@ -224,7 +224,6 @@ function extractValueFromDeclaration(declaration: Node): { value: string; isLite const initializer = declaration.getInitializer(); return extractValueFromExpression(initializer); } - // Handle export assignments (export default "value") if (Node.isExportAssignment(declaration)) { const expression = declaration.getExpression(); @@ -257,12 +256,22 @@ function extractValueFromExpression(expression: Node | undefined): { value: stri return undefined; } + // we are looking for a variableDeclaration and we need to resolve these to their root if they have tokens in them recursively + if (Node.isStringLiteral(expression)) { return { value: expression.getLiteralValue(), isLiteral: true, }; - } else if (Node.isTemplateExpression(expression) || Node.isPropertyAccessExpression(expression)) { + } else if (Node.isTemplateExpression(expression)) { + // We need to process template expression spans and then if they are also themselves something like a template expression or variable declartion, resolve that recursively. + console.log(expression.getTemplateSpans().map(span => span.getText())); + + return { + value: expression.getText(), + isLiteral: Node.isTemplateExpression(expression), + }; + } else if (Node.isPropertyAccessExpression(expression)) { return { value: expression.getText(), isLiteral: Node.isTemplateExpression(expression), From 2328dbae6915adde01ace9a22a837e0096e2c76b Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 26 Mar 2025 19:14:17 -0700 Subject: [PATCH 19/24] Format json with prettier automatically. --- .../token-analyzer-preview/library/package.json | 6 ++++-- .../token-analyzer-preview/library/src/index.ts | 11 ++++++++++- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/packages/react-components/token-analyzer-preview/library/package.json b/packages/react-components/token-analyzer-preview/library/package.json index 593a18a02e704..0511a05190973 100644 --- a/packages/react-components/token-analyzer-preview/library/package.json +++ b/packages/react-components/token-analyzer-preview/library/package.json @@ -22,7 +22,8 @@ "@fluentui/eslint-plugin": "*", "@fluentui/react-conformance": "*", "@fluentui/react-conformance-griffel": "*", - "@fluentui/scripts-api-extractor": "*" + "@fluentui/scripts-api-extractor": "*", + "@types/prettier": "2.7.2" }, "dependencies": { "@fluentui/react-jsx-runtime": "^9.0.45", @@ -32,7 +33,8 @@ "@griffel/react": "^1.5.22", "@swc/helpers": "^0.5.1", "ts-morph": "24.0.0", - "typescript": "5.2.2" + "typescript": "5.2.2", + "prettier": "2.8.8" }, "peerDependencies": { "@types/react": ">=16.14.0 <19.0.0", diff --git a/packages/react-components/token-analyzer-preview/library/src/index.ts b/packages/react-components/token-analyzer-preview/library/src/index.ts index 58a4d0a3594b5..072f1785bbded 100644 --- a/packages/react-components/token-analyzer-preview/library/src/index.ts +++ b/packages/react-components/token-analyzer-preview/library/src/index.ts @@ -2,6 +2,7 @@ import { Project } from 'ts-morph'; import { promises as fs } from 'fs'; import { relative } from 'path'; +import { format } from 'prettier'; import { findStyleFiles } from './fileOperations.js'; import { analyzeFile } from './astAnalyzer.js'; import { AnalysisResults, FileAnalysis } from './types.js'; @@ -48,7 +49,15 @@ async function analyzeProjectStyles( if (outputFile) { await measureAsync('write output file', async () => { - await fs.writeFile(outputFile, JSON.stringify(results, null, 2), 'utf8'); + const formatted = format(JSON.stringify(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}`); }); } From cc966219f952850727fda37cc16a868055d3ae6f Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 26 Mar 2025 19:14:55 -0700 Subject: [PATCH 20/24] update to dos --- .../token-analyzer-preview/library/README.md | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/packages/react-components/token-analyzer-preview/library/README.md b/packages/react-components/token-analyzer-preview/library/README.md index 9a6f35f75fba6..d26f8d04f42ba 100644 --- a/packages/react-components/token-analyzer-preview/library/README.md +++ b/packages/react-components/token-analyzer-preview/library/README.md @@ -4,23 +4,24 @@ A static analysis tool that scans your project's style files to track and analyz ## TODO +- we also need to ensure var analysis is done correctly after the refactor +- **This is high pri now since we have components in source using this technique (see buttonstyles.styles.ts)** Handle very complex cases like `var(--optional-token, var(--semantic-token, ${some-other-var-with-a-string-or-fallback}))`. This other var might be in another package or file as well. Currently we won't handle this level of depth but we could do symbol extraction in the future if needed to resolve the chain fully. This will likely require changes in importAnalyzer.ts and structural changes in the data we return. On top of needing to find referenced symbols within an aliased template string literal, we might also then need to parse out var fallbacks within short hands. IE: `padding: 'var(--first, var(--second)) 10px` and ensure the ordering is correct. +- ~~Format output with prettier when we save to ensure stage lint doesn't fail.~~ +- make sure this works with shorthand spread +- Look at the path info again. Do we ever need it? +- Convert token member within the analysis output to an array so we can hold multiple tokens. The order should be the order or priority. [0] being the highest pri with the last item in the array the least prioritized. +- Duplicate entries in useButtonStyles.styles.ts for useRootDisabledStyles.base.nested:hover.color - we might need to test case this +- ~~Button has some weird patterns in it where it uses makeResetStyles and then uses enums to pull in the styles, we might need to account for those as well.~~ - ~~Some property assignments can also be function calls, we need to process this scenario~~ - ~~`createCustomFocusIndicatorStyle` is a special function that is used throughout the library so we might be able to special case it~~ - ~~if we have file imports we need to analyze those such as importing base styles~~ -- we also need to ensure var analysis is done correctly after the refactor ~~- Manage makeResetStyles (likely same as makeStyles)~~ -- Button has some weird patterns in it where it uses makeResetStyles and then uses enums to pull in the styles, we might need to account for those as well. - ~~what if we have multiple `makeStyles` calls merged, are we handling that correctly or just nuking the conflicts in our output?~~ -- make sure this works with shorthand spread - as we update the functionality, we should update our test cases to reflect the new functionality we support and ensure it works. - ~~if we have functions we can't process (or other code for that matter), can we add that data into our report so we know to manually go deal with it?~~ - ~~assignedSlots in output to track which slots classes are applied to~~ - ~~Add variables full name to metadata (i.e. classNames.icon instead of just 'icon)~~ - ~~Module importing~~ -- Look at the path info again. Do we ever need it? -- Convert token member within the analysis output to an array so we can hold multiple tokens. The order should be the order or priority. [0] being the highest pri with the last item in the array the least prioritized. -- **This is high pri now since we have components in source using this technique (see buttonstyles.styles.ts)** Handle very complex cases like `var(--optional-token, var(--semantic-token, ${some-other-var-with-a-string-or-fallback}))`. This other var might be in another package or file as well. Currently we won't handle this level of depth but we could do symbol extraction in the future if needed to resolve the chain fully. This will likely require changes in importAnalyzer.ts and structural changes in the data we return. On top of needing to find referenced symbols within an aliased template string literal, we might also then need to parse out var fallbacks within short hands. IE: `padding: 'var(--first, var(--second)) 10px` and ensure the ordering is correct. -- Format output with prettier when we save to ensure stage lint doesn't fail. ## Features From af466ddd4d46654c05934ef32fb6ce30bff7c412 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 26 Mar 2025 19:15:12 -0700 Subject: [PATCH 21/24] Update so we recurse through imports and template string spans --- .../library/src/importAnalyzer.ts | 328 +++++++++++++++--- 1 file changed, 275 insertions(+), 53 deletions(-) diff --git a/packages/react-components/token-analyzer-preview/library/src/importAnalyzer.ts b/packages/react-components/token-analyzer-preview/library/src/importAnalyzer.ts index a45a7733bb936..4fa72a3c8f38b 100644 --- a/packages/react-components/token-analyzer-preview/library/src/importAnalyzer.ts +++ b/packages/react-components/token-analyzer-preview/library/src/importAnalyzer.ts @@ -5,6 +5,16 @@ import { TokenReference } from './types.js'; import { getModuleSourceFile } from './moduleResolver.js'; import { extractTokensFromCssVars } from './cssVarTokenExtractor.js'; +/** + * Represents a portion of a template expression + */ +interface TemplateSpan { + text: string; // The actual text content + isToken: boolean; // Whether this span is a token reference + isReference: boolean; // Whether this span is a reference to another variable + referenceName?: string; // The name of the referenced variable if isReference is true +} + /** * Represents a value imported from another module */ @@ -12,6 +22,11 @@ export interface ImportedValue { value: string; sourceFile: string; isLiteral: boolean; + + // Enhanced fields for recursive resolution + templateSpans?: TemplateSpan[]; // For template expressions with spans + resolvedTokens?: TokenReference[]; // Pre-extracted tokens from this value + visitedChain?: string[]; // Track resolution chain to prevent cycles } /** @@ -88,13 +103,14 @@ function processNamedImports( const { declaration, sourceFile: declarationFile } = exportInfo; // Extract the value from the declaration - const valueInfo = extractValueFromDeclaration(declaration); + const valueInfo = extractValueFromDeclaration(declaration, typeChecker); if (valueInfo) { importedValues.set(alias, { value: valueInfo.value, sourceFile: declarationFile.getFilePath(), isLiteral: valueInfo.isLiteral, + templateSpans: valueInfo.templateSpans, }); log(`Added imported value: ${alias} = ${valueInfo.value} from ${declarationFile.getFilePath()}`); @@ -127,13 +143,14 @@ function processDefaultImport( const { declaration, sourceFile: declarationFile } = exportInfo; // Extract the value from the declaration - const valueInfo = extractValueFromDeclaration(declaration); + const valueInfo = extractValueFromDeclaration(declaration, typeChecker); if (valueInfo) { importedValues.set(importName, { value: valueInfo.value, sourceFile: declarationFile.getFilePath(), isLiteral: valueInfo.isLiteral, + templateSpans: valueInfo.templateSpans, }); log(`Added default import: ${importName} = ${valueInfo.value} from ${declarationFile.getFilePath()}`); @@ -218,16 +235,19 @@ function findExportDeclaration( /** * Extract string value from a declaration node */ -function extractValueFromDeclaration(declaration: Node): { value: string; isLiteral: boolean } | undefined { +function extractValueFromDeclaration( + declaration: Node, + typeChecker: TypeChecker, +): { value: string; isLiteral: boolean; templateSpans?: TemplateSpan[] } | undefined { // Handle variable declarations if (Node.isVariableDeclaration(declaration)) { const initializer = declaration.getInitializer(); - return extractValueFromExpression(initializer); + return extractValueFromExpression(initializer, typeChecker); } // Handle export assignments (export default "value") if (Node.isExportAssignment(declaration)) { const expression = declaration.getExpression(); - return extractValueFromExpression(expression); + return extractValueFromExpression(expression, typeChecker); } // Handle named exports (export { x }) @@ -240,7 +260,7 @@ function extractValueFromDeclaration(declaration: Node): { value: string; isLite for (const varDecl of sourceFile.getDescendantsOfKind(SyntaxKind.VariableDeclaration)) { if (varDecl.getName() === name) { const initializer = varDecl.getInitializer(); - return extractValueFromExpression(initializer); + return extractValueFromExpression(initializer, typeChecker); } } } @@ -249,14 +269,36 @@ function extractValueFromDeclaration(declaration: Node): { value: string; isLite } /** - * Extract value from an expression node + * Extract value from an expression node with enhanced template literal handling and recursion */ -function extractValueFromExpression(expression: Node | undefined): { value: string; isLiteral: boolean } | undefined { +function extractValueFromExpression( + expression: Node | undefined, + typeChecker: TypeChecker, + visitedNodes: Set = new Set(), +): + | { + value: string; + isLiteral: boolean; + templateSpans?: TemplateSpan[]; + } + | undefined { if (!expression) { return undefined; } - // we are looking for a variableDeclaration and we need to resolve these to their root if they have tokens in them recursively + // Create a unique key for this expression to prevent infinite recursion + const expressionKey = `${expression.getSourceFile().getFilePath()}:${expression.getPos()}`; + if (visitedNodes.has(expressionKey)) { + log(`Skipping already visited expression: ${expressionKey}`); + return { + value: expression.getText(), + isLiteral: false, + }; + } + + // Add to visited nodes to prevent cycles + const newVisited = new Set(visitedNodes); + newVisited.add(expressionKey); if (Node.isStringLiteral(expression)) { return { @@ -264,17 +306,126 @@ function extractValueFromExpression(expression: Node | undefined): { value: stri isLiteral: true, }; } else if (Node.isTemplateExpression(expression)) { - // We need to process template expression spans and then if they are also themselves something like a template expression or variable declartion, resolve that recursively. - console.log(expression.getTemplateSpans().map(span => span.getText())); + // Process the template head and spans fully + const head = expression.getHead().getLiteralText(); + const spans = expression.getTemplateSpans(); + + let fullValue = head; + const templateSpans: TemplateSpan[] = []; + + // Add head as a non-token span if it's not empty + if (head) { + templateSpans.push({ + text: head, + isToken: false, + isReference: false, + }); + } + + // Process each span in the template expression + for (const span of spans) { + const spanExpr = span.getExpression(); + const spanText = spanExpr.getText(); + const literal = span.getLiteral().getLiteralText(); + + // Handle different types of expressions in template spans + if (Node.isPropertyAccessExpression(spanExpr) && spanText.startsWith('tokens.')) { + // Direct token reference in template span + templateSpans.push({ + text: spanText, + isToken: true, + isReference: false, + }); + fullValue += spanText; + } else if (Node.isIdentifier(spanExpr)) { + // Potential reference to another variable + templateSpans.push({ + text: spanText, + isToken: false, + isReference: true, + referenceName: spanText, + }); + fullValue += spanText; + } else { + // Other expression types - try to resolve recursively + const resolvedExpr = extractValueFromExpression(spanExpr, typeChecker, newVisited); + if (resolvedExpr) { + if (resolvedExpr.templateSpans) { + // If it has its own spans, include them + templateSpans.push(...resolvedExpr.templateSpans); + } else { + // Otherwise add the value + templateSpans.push({ + text: resolvedExpr.value, + isToken: false, + isReference: false, + }); + } + fullValue += resolvedExpr.value; + } else { + // Fallback to the raw text if we can't resolve + templateSpans.push({ + text: spanText, + isToken: false, + isReference: false, + }); + fullValue += spanText; + } + } + + // Add the literal part that follows the expression + if (literal) { + templateSpans.push({ + text: literal, + isToken: false, + isReference: false, + }); + fullValue += literal; + } + } + + return { + value: fullValue, + isLiteral: true, + templateSpans, + }; + } else if (Node.isIdentifier(expression)) { + // Try to resolve the identifier to its value + const symbol = expression.getSymbol(); + if (!symbol) { + return { + value: expression.getText(), + isLiteral: false, + }; + } + + // Get the declaration of this identifier + const decl = symbol.getValueDeclaration() || symbol.getDeclarations()?.[0]; + if (!decl) { + return { + value: expression.getText(), + isLiteral: false, + }; + } + + // If it's a variable declaration, get its initializer + if (Node.isVariableDeclaration(decl)) { + const initializer = decl.getInitializer(); + if (initializer) { + // Recursively resolve the initializer + return extractValueFromExpression(initializer, typeChecker, newVisited); + } + } return { value: expression.getText(), - isLiteral: Node.isTemplateExpression(expression), + isLiteral: false, }; } else if (Node.isPropertyAccessExpression(expression)) { + // Handle tokens.xyz or other property access return { value: expression.getText(), - isLiteral: Node.isTemplateExpression(expression), + isLiteral: false, }; } else if (Node.isNoSubstitutionTemplateLiteral(expression)) { return { @@ -283,11 +434,12 @@ function extractValueFromExpression(expression: Node | undefined): { value: stri }; } + // Default case for unhandled expression types return undefined; } /** - * Process string tokens in imported values + * Process string tokens in imported values with enhanced recursive resolution */ export function processImportedStringTokens( importedValues: Map, @@ -295,46 +447,111 @@ export function processImportedStringTokens( value: string, path: string[] = [], TOKEN_REGEX: RegExp, + visited: Set = new Set(), ): TokenReference[] { const tokens: TokenReference[] = []; + // Prevent infinite recursion with cycle detection + if (visited.has(value)) { + log(`Skipping circular reference: ${value}`); + return tokens; + } + + // Create a new set with the current value added + const newVisited = new Set(visited); + newVisited.add(value); + // Check if the value is an imported value reference if (importedValues.has(value)) { const importedValue = importedValues.get(value)!; + // If we've already pre-resolved tokens for this value, use them + if (importedValue.resolvedTokens) { + return importedValue.resolvedTokens.map(token => ({ + ...token, + property: propertyName, // Update property name for current context + path: path, // Update path for current context + })); + } + if (importedValue.isLiteral) { - // Process literal values (strings and template literals) - - // First, check for direct token references - const matches = importedValue.value.match(TOKEN_REGEX); - if (matches) { - matches.forEach(match => { - tokens.push({ - property: propertyName, - token: match, - path, - isVariableReference: true, - sourceFile: importedValue.sourceFile, + if (importedValue.templateSpans) { + // Process template spans specially + for (const span of importedValue.templateSpans) { + if (span.isToken) { + // Direct token reference in span + tokens.push({ + property: propertyName, + token: span.text, + path, + isVariableReference: true, + sourceFile: importedValue.sourceFile, + }); + } else if (span.isReference && span.referenceName && importedValues.has(span.referenceName)) { + // Reference to another imported value - process recursively + const spanTokens = processImportedStringTokens( + importedValues, + propertyName, + span.referenceName, + path, + TOKEN_REGEX, + newVisited, + ); + tokens.push(...spanTokens); + } else if (span.text.includes('var(')) { + // Check for CSS variables in the span text + const cssVarTokens = extractTokensFromCssVars(span.text, propertyName, path, TOKEN_REGEX); + cssVarTokens.forEach(token => { + tokens.push({ + ...token, + isVariableReference: true, + sourceFile: importedValue.sourceFile, + }); + }); + } else { + // Check for direct token matches in non-reference spans + const matches = span.text.match(TOKEN_REGEX); + if (matches) { + matches.forEach(match => { + tokens.push({ + property: propertyName, + token: match, + path, + isVariableReference: true, + sourceFile: importedValue.sourceFile, + }); + }); + } + } + } + } else { + // Standard processing for literals without spans + // First, check for direct token references + const matches = importedValue.value.match(TOKEN_REGEX); + if (matches) { + matches.forEach(match => { + tokens.push({ + property: propertyName, + token: match, + path, + isVariableReference: true, + sourceFile: importedValue.sourceFile, + }); }); - }); - } else if (importedValue.value.includes('var(')) { - // Then check for CSS variable patterns that might contain tokens - const cssVarTokens = extractTokensFromCssVars(importedValue.value, propertyName, path, TOKEN_REGEX); - - // Add CSS variable tokens with the source information - cssVarTokens.forEach(token => { - tokens.push({ - ...token, - isVariableReference: true, - sourceFile: importedValue.sourceFile, + } else if (importedValue.value.includes('var(')) { + // Then check for CSS variable patterns + const cssVarTokens = extractTokensFromCssVars(importedValue.value, propertyName, path, TOKEN_REGEX); + cssVarTokens.forEach(token => { + tokens.push({ + ...token, + isVariableReference: true, + sourceFile: importedValue.sourceFile, + }); }); - }); + } } } else { - // Process non-literal values (property access expressions, etc.) - - // Check if the value directly matches the token pattern (tokens.someToken) - const matches = importedValue.value.match(TOKEN_REGEX); + // Non-literal values (like property access expressions) if (importedValue.value.match(TOKEN_REGEX)) { tokens.push({ property: propertyName, @@ -343,20 +560,25 @@ export function processImportedStringTokens( isVariableReference: true, sourceFile: importedValue.sourceFile, }); - } else if (matches) { - // For template expressions, we might need to extract tokens from parts of the expression - // This is a simplified approach - might need enhancement for complex template expressions - matches.forEach(match => { - tokens.push({ - property: propertyName, - token: match, - path, - isVariableReference: true, - sourceFile: importedValue.sourceFile, + } else { + // Check for any token references in the value + const matches = importedValue.value.match(TOKEN_REGEX); + if (matches) { + matches.forEach(match => { + tokens.push({ + property: propertyName, + token: match, + path, + isVariableReference: true, + sourceFile: importedValue.sourceFile, + }); }); - }); + } } } + + // Cache the resolved tokens for future use + importedValue.resolvedTokens = tokens.map(token => ({ ...token })); } return tokens; From d3659b618ec5242b9b21db377aa77399c2533631 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 28 Mar 2025 03:57:09 -0700 Subject: [PATCH 22/24] Adding spread handling updating tests and sample data centralize token detection --- .../library/src/__tests__/analyzer.test.ts | 6 + .../library/src/__tests__/sample-styles.ts | 1 + .../library/src/astAnalyzer.ts | 160 ++++++++++++++---- .../library/src/cssVarTokenExtractor.ts | 13 +- .../library/src/importAnalyzer.ts | 65 +++---- .../library/src/tokenUtils.ts | 94 ++++++++++ 6 files changed, 250 insertions(+), 89 deletions(-) create mode 100644 packages/react-components/token-analyzer-preview/library/src/tokenUtils.ts diff --git a/packages/react-components/token-analyzer-preview/library/src/__tests__/analyzer.test.ts b/packages/react-components/token-analyzer-preview/library/src/__tests__/analyzer.test.ts index 18c0521aadbd5..1d63908422a7a 100644 --- a/packages/react-components/token-analyzer-preview/library/src/__tests__/analyzer.test.ts +++ b/packages/react-components/token-analyzer-preview/library/src/__tests__/analyzer.test.ts @@ -43,6 +43,12 @@ describe('Token Analyzer', () => { token: 'tokens.colorNeutralForeground1', }), ); + expect(styles.useStyles.root.tokens).toContainEqual( + expect.objectContaining({ + property: 'borderRightColor', + token: 'tokens.colorNeutralStrokeDisabled', + }), + ); // Verify anotherSlot styles expect(styles.useStyles.anotherSlot.tokens).toContainEqual( diff --git a/packages/react-components/token-analyzer-preview/library/src/__tests__/sample-styles.ts b/packages/react-components/token-analyzer-preview/library/src/__tests__/sample-styles.ts index cac52146c4d6a..8deebab2fb35d 100644 --- a/packages/react-components/token-analyzer-preview/library/src/__tests__/sample-styles.ts +++ b/packages/react-components/token-analyzer-preview/library/src/__tests__/sample-styles.ts @@ -10,6 +10,7 @@ const useStyles = makeStyles({ root: { color: tokens.colorNeutralForeground1, backgroundColor: tokens.colorNeutralBackground1, + ...shorthands.borderColor(tokens.colorNeutralStrokeDisabled), ':hover': { color: tokens.colorNeutralForegroundHover, } diff --git a/packages/react-components/token-analyzer-preview/library/src/astAnalyzer.ts b/packages/react-components/token-analyzer-preview/library/src/astAnalyzer.ts index 78ad527bfadf5..da0612215efaa 100644 --- a/packages/react-components/token-analyzer-preview/library/src/astAnalyzer.ts +++ b/packages/react-components/token-analyzer-preview/library/src/astAnalyzer.ts @@ -1,5 +1,5 @@ /* eslint-disable no-console */ -import { Project, Node, SourceFile, PropertyAssignment } from 'ts-morph'; +import { Project, Node, SourceFile, PropertyAssignment, SpreadAssignment } from 'ts-morph'; import { TokenReference, StyleAnalysis, @@ -13,6 +13,7 @@ import { import { log, measure, measureAsync } from './debugUtils.js'; import { analyzeImports, processImportedStringTokens, ImportedValue } from './importAnalyzer.js'; import { extractTokensFromCssVars } from './cssVarTokenExtractor.js'; +import { extractTokensFromText, getPropertiesForShorthand, isTokenReference } from './tokenUtils'; const makeResetStylesToken = 'resetStyles'; @@ -32,17 +33,17 @@ interface VariableMapping { * Property names are derived from the actual CSS property in the path, * not the object key containing them. * - * @param prop The property assignment to process + * @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, + prop: PropertyAssignment | SpreadAssignment, importedValues: Map | undefined = undefined, isResetStyles?: Boolean, ): TokenReference[] { const tokens: TokenReference[] = []; - const parentName = prop.getName(); + const parentName = Node.isPropertyAssignment(prop) ? prop.getName() : ''; function processNode(node?: Node, path: string[] = []): void { if (!node) { @@ -50,7 +51,7 @@ function processStyleProperty( } // If we're processing a reset style, we need to add the parent name to the path - if (isResetStyles && path.length === 0) { + if (isResetStyles && path.length === 0 && parentName) { path.push(parentName); } @@ -64,8 +65,8 @@ function processStyleProperty( tokens.push(...cssVarTokens); } else { // Check for direct token references - const matches = text.match(TOKEN_REGEX); - if (matches) { + const matches = extractTokensFromText(node); + if (matches.length > 0) { matches.forEach(match => { tokens.push({ property: path[path.length - 1] || parentName, @@ -79,8 +80,8 @@ function processStyleProperty( const text = node.getText(); // First check if it matches the token regex directly - const matches = text.match(TOKEN_REGEX); - if (matches) { + const matches = extractTokensFromText(node); + if (matches.length > 0) { matches.forEach(match => { tokens.push({ property: path[path.length - 1] || parentName, @@ -103,7 +104,8 @@ function processStyleProperty( } } else if (Node.isPropertyAccessExpression(node)) { const text = node.getText(); - if (text.startsWith('tokens.')) { + const isToken = isTokenReference(text); + if (isToken) { tokens.push({ property: path[path.length - 1] || parentName, token: text, @@ -115,8 +117,14 @@ function processStyleProperty( if (Node.isPropertyAssignment(childProp)) { const childName = childProp.getName(); processNode(childProp.getInitializer(), [...path, childName]); + } else if (Node.isSpreadAssignment(childProp)) { + // Handle spread elements in object literals + processNode(childProp.getExpression(), path); } }); + } else if (Node.isSpreadAssignment(node)) { + // Handle spread elements + processNode(node.getExpression(), path); } else if (Node.isCallExpression(node) && node.getExpression().getText() === 'createCustomFocusIndicatorStyle') { const focus = `:focus`; const focusWithin = `:focus-within`; @@ -150,39 +158,119 @@ function processStyleProperty( }); } } else if (Node.isCallExpression(node)) { - // Generic handling of functions that are not whitelisted - stored passed tokens under function name + // Process calls like shorthands.borderColor(tokens.color) const functionName = node.getExpression().getText(); - node.getArguments().forEach(argument => { - if (Node.isObjectLiteralExpression(argument)) { - argument.getProperties().forEach(property => { - if (Node.isPropertyAssignment(property)) { - const childName = property.getName(); - processNode(property.getInitializer(), [...path, functionName, childName]); + // we should pass the number of arguments so we can properly map which overload is being called. + const affectedProperties = getPropertiesForShorthand(functionName); + + if (affectedProperties.length > 0) { + // Process each argument and apply it to all affected properties + node.getArguments().forEach(argument => { + processNodeForAffectedProperties(argument, affectedProperties, path); + }); + } else { + // Generic handling of functions that are not whitelisted + node.getArguments().forEach(argument => { + if (Node.isObjectLiteralExpression(argument)) { + argument.getProperties().forEach(property => { + if (Node.isPropertyAssignment(property)) { + const childName = property.getName(); + processNode(property.getInitializer(), [...path, functionName, childName]); + } + }); + } + // Check for string literals in function arguments that might contain CSS variables with tokens + if (Node.isStringLiteral(argument)) { + const text = argument.getText().replace(/['"]/g, ''); + if (text.includes('var(')) { + const cssVarTokens = extractTokensFromCssVars( + text, + path[path.length - 1] || parentName, + [...path, functionName], + TOKEN_REGEX, + ); + tokens.push(...cssVarTokens); } - }); - } - // Check for string literals in function arguments that might contain CSS variables with tokens - if (Node.isStringLiteral(argument)) { - const text = argument.getText().replace(/['"]/g, ''); - if (text.includes('var(')) { - const cssVarTokens = extractTokensFromCssVars( - text, - path[path.length - 1] || parentName, - [...path, functionName], - TOKEN_REGEX, - ); - tokens.push(...cssVarTokens); } - } - }); + }); + } } } - const initializer = prop.getInitializer(); - if (initializer) { - processNode(initializer); + // Helper function to process nodes for multiple affected properties + function processNodeForAffectedProperties(node: Node, properties: string[], basePath: string[]): void { + if (!node) { + return; + } + + // If this is a direct token reference + if (Node.isPropertyAccessExpression(node) && isTokenReference(node)) { + properties.forEach(property => { + tokens.push({ + property, + token: node.getText(), + path: basePath, + }); + }); + return; + } + + // If this is an identifier that might be a variable + if (Node.isIdentifier(node) && importedValues && importedValues.has(node.getText())) { + properties.forEach(property => { + const importTokens = processImportedStringTokens( + importedValues, + property, + node.getText(), + basePath, + TOKEN_REGEX, + ); + tokens.push(...importTokens); + }); + return; + } + + // For other node types, process them normally but with each property + if (Node.isStringLiteral(node) || Node.isTemplateExpression(node)) { + const text = node.getText().replace(/['"]/g, ''); + + // Check for tokens in the text + const matches = extractTokensFromText(node); + if (matches.length > 0) { + properties.forEach(property => { + matches.forEach(match => { + tokens.push({ + property, + token: match, + path: basePath, + }); + }); + }); + } + + // Check for CSS vars + if (text.includes('var(')) { + properties.forEach(property => { + const cssVarTokens = extractTokensFromCssVars(text, property, basePath, TOKEN_REGEX); + tokens.push(...cssVarTokens); + }); + } + } + + // For any other complex expressions, process them normally + else { + processNode(node, basePath); + } } + if (Node.isPropertyAssignment(prop)) { + const initializer = prop.getInitializer(); + if (initializer) { + processNode(initializer); + } + } else if (Node.isSpreadAssignment(prop)) { + processNode(prop.getExpression()); + } return tokens; } @@ -431,8 +519,6 @@ async function analyzeFile(filePath: string, project: Project): Promise analyzeImports(sourceFile, project)); - console.log(importedValues); - // Second pass: Analyze mergeClasses const styleMappings = measure('analyze mergeClasses', () => analyzeMergeClasses(sourceFile)); diff --git a/packages/react-components/token-analyzer-preview/library/src/cssVarTokenExtractor.ts b/packages/react-components/token-analyzer-preview/library/src/cssVarTokenExtractor.ts index abadf6c6d7ee2..ed8753e920165 100644 --- a/packages/react-components/token-analyzer-preview/library/src/cssVarTokenExtractor.ts +++ b/packages/react-components/token-analyzer-preview/library/src/cssVarTokenExtractor.ts @@ -1,6 +1,7 @@ // cssVarTokenExtractor.ts import { log } from './debugUtils.js'; import { TokenReference } from './types.js'; +import { extractTokensFromText } from './tokenUtils.js'; /** * Extracts token references from CSS variable syntax including nested fallback chains @@ -23,8 +24,8 @@ export function extractTokensFromCssVars( let testValue = value; // Direct token matches in the string - const directMatches = testValue.match(TOKEN_REGEX); - if (directMatches) { + const directMatches = extractTokensFromText(testValue); + if (directMatches.length > 0) { directMatches.forEach(match => { testValue = testValue.replace(match, ''); // Remove direct matches from the string tokens.push({ @@ -52,8 +53,8 @@ export function extractTokensFromCssVars( log(` - Fallback: ${fallback}`); // Check if the variable name contains a token reference - const varNameTokens = varName.match(TOKEN_REGEX); - if (varNameTokens) { + const varNameTokens = extractTokensFromText(varName); + if (varNameTokens.length > 0) { varNameTokens.forEach(token => { tokens.push({ property: propertyName, @@ -71,8 +72,8 @@ export function extractTokensFromCssVars( tokens.push(...fallbackTokens); } else { // Check for direct token references in the fallback - const fallbackTokens = fallback.match(TOKEN_REGEX); - if (fallbackTokens) { + const fallbackTokens = extractTokensFromText(fallback); + if (fallbackTokens.length > 0) { fallbackTokens.forEach(token => { tokens.push({ property: propertyName, diff --git a/packages/react-components/token-analyzer-preview/library/src/importAnalyzer.ts b/packages/react-components/token-analyzer-preview/library/src/importAnalyzer.ts index 4fa72a3c8f38b..bd76e6e9a1512 100644 --- a/packages/react-components/token-analyzer-preview/library/src/importAnalyzer.ts +++ b/packages/react-components/token-analyzer-preview/library/src/importAnalyzer.ts @@ -1,9 +1,10 @@ // importAnalyzer.ts import { Project, Node, SourceFile, ImportDeclaration, Symbol, TypeChecker, SyntaxKind } from 'ts-morph'; import { log } from './debugUtils.js'; -import { TokenReference } from './types.js'; +import { TokenReference, TOKEN_REGEX } from './types.js'; import { getModuleSourceFile } from './moduleResolver.js'; import { extractTokensFromCssVars } from './cssVarTokenExtractor.js'; +import { isTokenReference, extractTokensFromText } from './tokenUtils.js'; /** * Represents a portion of a template expression @@ -23,10 +24,9 @@ export interface ImportedValue { sourceFile: string; isLiteral: boolean; - // Enhanced fields for recursive resolution + // Enhanced fields for template processing templateSpans?: TemplateSpan[]; // For template expressions with spans resolvedTokens?: TokenReference[]; // Pre-extracted tokens from this value - visitedChain?: string[]; // Track resolution chain to prevent cycles } /** @@ -269,12 +269,11 @@ function extractValueFromDeclaration( } /** - * Extract value from an expression node with enhanced template literal handling and recursion + * Extract value from an expression node with enhanced template literal handling */ function extractValueFromExpression( expression: Node | undefined, typeChecker: TypeChecker, - visitedNodes: Set = new Set(), ): | { value: string; @@ -286,20 +285,6 @@ function extractValueFromExpression( return undefined; } - // Create a unique key for this expression to prevent infinite recursion - const expressionKey = `${expression.getSourceFile().getFilePath()}:${expression.getPos()}`; - if (visitedNodes.has(expressionKey)) { - log(`Skipping already visited expression: ${expressionKey}`); - return { - value: expression.getText(), - isLiteral: false, - }; - } - - // Add to visited nodes to prevent cycles - const newVisited = new Set(visitedNodes); - newVisited.add(expressionKey); - if (Node.isStringLiteral(expression)) { return { value: expression.getLiteralValue(), @@ -329,7 +314,7 @@ function extractValueFromExpression( const literal = span.getLiteral().getLiteralText(); // Handle different types of expressions in template spans - if (Node.isPropertyAccessExpression(spanExpr) && spanText.startsWith('tokens.')) { + if (Node.isPropertyAccessExpression(spanExpr) && isTokenReference(spanExpr)) { // Direct token reference in template span templateSpans.push({ text: spanText, @@ -348,7 +333,7 @@ function extractValueFromExpression( fullValue += spanText; } else { // Other expression types - try to resolve recursively - const resolvedExpr = extractValueFromExpression(spanExpr, typeChecker, newVisited); + const resolvedExpr = extractValueFromExpression(spanExpr, typeChecker); if (resolvedExpr) { if (resolvedExpr.templateSpans) { // If it has its own spans, include them @@ -413,7 +398,7 @@ function extractValueFromExpression( const initializer = decl.getInitializer(); if (initializer) { // Recursively resolve the initializer - return extractValueFromExpression(initializer, typeChecker, newVisited); + return extractValueFromExpression(initializer, typeChecker); } } @@ -439,28 +424,17 @@ function extractValueFromExpression( } /** - * Process string tokens in imported values with enhanced recursive resolution + * Process string tokens in imported values */ export function processImportedStringTokens( importedValues: Map, propertyName: string, value: string, path: string[] = [], - TOKEN_REGEX: RegExp, - visited: Set = new Set(), + tokenRegex: RegExp = TOKEN_REGEX, ): TokenReference[] { const tokens: TokenReference[] = []; - // Prevent infinite recursion with cycle detection - if (visited.has(value)) { - log(`Skipping circular reference: ${value}`); - return tokens; - } - - // Create a new set with the current value added - const newVisited = new Set(visited); - newVisited.add(value); - // Check if the value is an imported value reference if (importedValues.has(value)) { const importedValue = importedValues.get(value)!; @@ -494,13 +468,12 @@ export function processImportedStringTokens( propertyName, span.referenceName, path, - TOKEN_REGEX, - newVisited, + tokenRegex, ); tokens.push(...spanTokens); } else if (span.text.includes('var(')) { // Check for CSS variables in the span text - const cssVarTokens = extractTokensFromCssVars(span.text, propertyName, path, TOKEN_REGEX); + const cssVarTokens = extractTokensFromCssVars(span.text, propertyName, path, tokenRegex); cssVarTokens.forEach(token => { tokens.push({ ...token, @@ -510,8 +483,8 @@ export function processImportedStringTokens( }); } else { // Check for direct token matches in non-reference spans - const matches = span.text.match(TOKEN_REGEX); - if (matches) { + const matches = extractTokensFromText(span.text); + if (matches.length > 0) { matches.forEach(match => { tokens.push({ property: propertyName, @@ -527,8 +500,8 @@ export function processImportedStringTokens( } else { // Standard processing for literals without spans // First, check for direct token references - const matches = importedValue.value.match(TOKEN_REGEX); - if (matches) { + const matches = extractTokensFromText(importedValue.value); + if (matches.length > 0) { matches.forEach(match => { tokens.push({ property: propertyName, @@ -540,7 +513,7 @@ export function processImportedStringTokens( }); } else if (importedValue.value.includes('var(')) { // Then check for CSS variable patterns - const cssVarTokens = extractTokensFromCssVars(importedValue.value, propertyName, path, TOKEN_REGEX); + const cssVarTokens = extractTokensFromCssVars(importedValue.value, propertyName, path, tokenRegex); cssVarTokens.forEach(token => { tokens.push({ ...token, @@ -552,7 +525,7 @@ export function processImportedStringTokens( } } else { // Non-literal values (like property access expressions) - if (importedValue.value.match(TOKEN_REGEX)) { + if (isTokenReference(importedValue.value)) { tokens.push({ property: propertyName, token: importedValue.value, @@ -562,8 +535,8 @@ export function processImportedStringTokens( }); } else { // Check for any token references in the value - const matches = importedValue.value.match(TOKEN_REGEX); - if (matches) { + const matches = extractTokensFromText(importedValue.value); + if (matches.length > 0) { matches.forEach(match => { tokens.push({ property: propertyName, diff --git a/packages/react-components/token-analyzer-preview/library/src/tokenUtils.ts b/packages/react-components/token-analyzer-preview/library/src/tokenUtils.ts new file mode 100644 index 0000000000000..12cf9b02e9d6d --- /dev/null +++ b/packages/react-components/token-analyzer-preview/library/src/tokenUtils.ts @@ -0,0 +1,94 @@ +// tokenUtils.ts +import { Node, Symbol } from 'ts-morph'; +import { TOKEN_REGEX } from './types.js'; + +/** + * Centralizes token detection logic to make future changes easier + * @param textOrNode The text or Node to check for token references + * @returns true if the text/node contains a token reference + */ +export function isTokenReference(textOrNode: string | Node | Symbol): boolean { + // If we have a Node or Symbol, extract the text to check + let text: string; + + if (typeof textOrNode === 'string') { + text = textOrNode; + } else if (Node.isNode(textOrNode)) { + text = textOrNode.getText(); + } else if (textOrNode instanceof Symbol) { + // For symbols, we need to check the declarations + const declarations = textOrNode.getDeclarations(); + if (!declarations || declarations.length === 0) { + return false; + } + + // Get text from the first declaration + text = declarations[0].getText(); + } else { + return false; + } + // IMPORTANT: Reset lastIndex to prevent issues with the global flag + TOKEN_REGEX.lastIndex = 0; + const test = TOKEN_REGEX.test(text); + return test; +} + +/** + * Extracts all token references from a text string or Node + * @param textOrNode The text or Node to extract tokens from + * @returns Array of token reference strings + */ +export function extractTokensFromText(textOrNode: string | Node | Symbol): string[] { + // If we have a Node or Symbol, extract the text to check + let text: string; + + if (typeof textOrNode === 'string') { + text = textOrNode; + } else if (Node.isNode(textOrNode)) { + text = textOrNode.getText(); + } else if (textOrNode instanceof Symbol) { + // For symbols, we need to check the declarations + const declarations = textOrNode.getDeclarations(); + if (!declarations || declarations.length === 0) { + return []; + } + + // Get text from the first declaration + text = declarations[0].getText(); + } else { + return []; + } + + const matches = text.match(TOKEN_REGEX); + return matches || []; +} + +/** + * Maps shorthand function names to the CSS properties they affect + * @param functionName The name of the shorthand function (e.g., "borderColor" or "shorthands.borderColor") + * @returns Array of CSS property names affected by this shorthand + */ +export function getPropertiesForShorthand(functionName: string): string[] { + const shorthandMap: Record = { + // Border shorthands + borderColor: ['borderTopColor', 'borderRightColor', 'borderBottomColor', 'borderLeftColor'], + border: ['borderWidth', 'borderStyle', 'borderColor'], + borderRadius: ['borderTopLeftRadius', 'borderTopRightRadius', 'borderBottomRightRadius', 'borderBottomLeftRadius'], + + // Padding/margin shorthands + padding: ['paddingTop', 'paddingRight', 'paddingBottom', 'paddingLeft'], + margin: ['marginTop', 'marginRight', 'marginBottom', 'marginLeft'], + + // Other common shorthands + flex: ['flexGrow', 'flexShrink', 'flexBasis'], + gap: ['rowGap', 'columnGap'], + overflow: ['overflowX', 'overflowY'], + gridArea: ['gridRowStart', 'gridColumnStart', 'gridRowEnd', 'gridColumnEnd'], + inset: ['top', 'right', 'bottom', 'left'], + }; + + // Extract base function name if it's a qualified name (e.g., shorthands.borderColor -> borderColor) + const baseName = functionName.includes('.') ? functionName.split('.').pop() : functionName; + + return baseName && shorthandMap[baseName!] ? shorthandMap[baseName!] : []; +} From 9bd14cc3092af6a60fc278206dfd6b53a2a58a0a Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 28 Mar 2025 15:24:58 -0700 Subject: [PATCH 23/24] fix spread analysis --- .../token-analyzer-preview/library/src/astAnalyzer.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/react-components/token-analyzer-preview/library/src/astAnalyzer.ts b/packages/react-components/token-analyzer-preview/library/src/astAnalyzer.ts index da0612215efaa..3b02ce229215e 100644 --- a/packages/react-components/token-analyzer-preview/library/src/astAnalyzer.ts +++ b/packages/react-components/token-analyzer-preview/library/src/astAnalyzer.ts @@ -13,7 +13,7 @@ import { import { log, measure, measureAsync } from './debugUtils.js'; import { analyzeImports, processImportedStringTokens, ImportedValue } from './importAnalyzer.js'; import { extractTokensFromCssVars } from './cssVarTokenExtractor.js'; -import { extractTokensFromText, getPropertiesForShorthand, isTokenReference } from './tokenUtils'; +import { extractTokensFromText, getPropertiesForShorthand, isTokenReference } from './tokenUtils.js'; const makeResetStylesToken = 'resetStyles'; @@ -209,7 +209,7 @@ function processStyleProperty( tokens.push({ property, token: node.getText(), - path: basePath, + path: basePath.concat(property), }); }); return; @@ -271,6 +271,7 @@ function processStyleProperty( } else if (Node.isSpreadAssignment(prop)) { processNode(prop.getExpression()); } + return tokens; } @@ -347,7 +348,9 @@ function analyzeMergeClasses(sourceFile: SourceFile): StyleMapping[] { */ function createStyleContent(tokens: TokenReference[]): StyleContent { const content: StyleContent = { - tokens: tokens.filter(t => t.path.length === 1), + tokens: tokens.filter(t => { + return t.path.length === 1; + }), }; // Nested structures have paths longer than 1 From e2c51eedbc92e2dbd4454f50828a381e5db15844 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 28 Mar 2025 16:27:38 -0700 Subject: [PATCH 24/24] sorting json output for consistent results updating button analysis update todos --- .../token-analyzer-preview/library/README.md | 6 +- .../library/analysis.json | 353 ++++++++++++++++++ .../library/src/index.ts | 17 +- 3 files changed, 373 insertions(+), 3 deletions(-) diff --git a/packages/react-components/token-analyzer-preview/library/README.md b/packages/react-components/token-analyzer-preview/library/README.md index d26f8d04f42ba..6fcf0d787a2bb 100644 --- a/packages/react-components/token-analyzer-preview/library/README.md +++ b/packages/react-components/token-analyzer-preview/library/README.md @@ -5,12 +5,14 @@ A static analysis tool that scans your project's style files to track and analyz ## TODO - we also need to ensure var analysis is done correctly after the refactor -- **This is high pri now since we have components in source using this technique (see buttonstyles.styles.ts)** Handle very complex cases like `var(--optional-token, var(--semantic-token, ${some-other-var-with-a-string-or-fallback}))`. This other var might be in another package or file as well. Currently we won't handle this level of depth but we could do symbol extraction in the future if needed to resolve the chain fully. This will likely require changes in importAnalyzer.ts and structural changes in the data we return. On top of needing to find referenced symbols within an aliased template string literal, we might also then need to parse out var fallbacks within short hands. IE: `padding: 'var(--first, var(--second)) 10px` and ensure the ordering is correct. +- ~~**This is high pri now since we have components in source using this technique (see buttonstyles.styles.ts)** Handle very complex cases like `var(--optional-token, var(--semantic-token, ${some-other-var-with-a-string-or-fallback}))`. This other var might be in another package or file as well. Currently we won't handle this level of depth but we could do symbol extraction in the future if needed to resolve the chain fully. This will likely require changes in importAnalyzer.ts and structural changes in the data we return. On top of needing to find referenced symbols within an aliased template string literal, we might also then need to parse out var fallbacks within short hands. IE: `padding: 'var(--first, var(--second)) 10px` and ensure the ordering is correct.~~ - ~~Format output with prettier when we save to ensure stage lint doesn't fail.~~ -- make sure this works with shorthand spread +- ~~make sure this works with shorthand spread~~ - Look at the path info again. Do we ever need it? - Convert token member within the analysis output to an array so we can hold multiple tokens. The order should be the order or priority. [0] being the highest pri with the last item in the array the least prioritized. - Duplicate entries in useButtonStyles.styles.ts for useRootDisabledStyles.base.nested:hover.color - we might need to test case this +- ~~We've added the ability to analyze spreads but there's an issue where we find the tokens and call them out but they get nuked somewhere before we return them. Need to trace that and fix.~~ +- Add makeResetStyles specific tests in analyzer to ensure we process those correctly. - ~~Button has some weird patterns in it where it uses makeResetStyles and then uses enums to pull in the styles, we might need to account for those as well.~~ - ~~Some property assignments can also be function calls, we need to process this scenario~~ - ~~`createCustomFocusIndicatorStyle` is a special function that is used throughout the library so we might be able to special case it~~ diff --git a/packages/react-components/token-analyzer-preview/library/analysis.json b/packages/react-components/token-analyzer-preview/library/analysis.json index af495c3d3c1d3..bd0956ec034ba 100644 --- a/packages/react-components/token-analyzer-preview/library/analysis.json +++ b/packages/react-components/token-analyzer-preview/library/analysis.json @@ -103,6 +103,15 @@ "path": [] } ] + }, + "'@supports (-moz-appearance:button)'": { + "tokens": [ + { + "property": "boxShadow", + "token": "tokens.colorStrokeFocus2", + "path": [] + } + ] } }, "isResetStyles": true, @@ -402,6 +411,26 @@ "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", @@ -425,6 +454,26 @@ "token": "tokens.colorNeutralBackgroundDisabled", "path": [] }, + { + "property": "borderTopColor", + "token": "tokens.colorNeutralStrokeDisabled", + "path": [] + }, + { + "property": "borderRightColor", + "token": "tokens.colorNeutralStrokeDisabled", + "path": [] + }, + { + "property": "borderBottomColor", + "token": "tokens.colorNeutralStrokeDisabled", + "path": [] + }, + { + "property": "borderLeftColor", + "token": "tokens.colorNeutralStrokeDisabled", + "path": [] + }, { "property": "color", "token": "tokens.colorNeutralForegroundDisabled", @@ -444,6 +493,26 @@ "token": "tokens.colorNeutralBackgroundDisabled", "path": [] }, + { + "property": "borderTopColor", + "token": "tokens.colorNeutralStrokeDisabled", + "path": [] + }, + { + "property": "borderRightColor", + "token": "tokens.colorNeutralStrokeDisabled", + "path": [] + }, + { + "property": "borderBottomColor", + "token": "tokens.colorNeutralStrokeDisabled", + "path": [] + }, + { + "property": "borderLeftColor", + "token": "tokens.colorNeutralStrokeDisabled", + "path": [] + }, { "property": "color", "token": "tokens.colorNeutralForegroundDisabled", @@ -581,6 +650,110 @@ }, "assignedVariables": ["rootFocusStyles"] }, + "primary": { + "tokens": [], + "nested": { + ":focus": { + "tokens": [ + { + "property": "boxShadow", + "token": "tokens.shadow2", + "path": [] + }, + { + "property": "boxShadow", + "token": "tokens.strokeWidthThin", + "path": [] + }, + { + "property": "boxShadow", + "token": "tokens.colorStrokeFocus2", + "path": [] + }, + { + "property": "boxShadow", + "token": "tokens.strokeWidthThick", + "path": [] + }, + { + "property": "boxShadow", + "token": "tokens.colorNeutralForegroundOnBrand", + "path": [] + }, + { + "property": "boxShadow", + "token": "tokens.shadow2", + "path": [] + }, + { + "property": "boxShadow", + "token": "tokens.strokeWidthThin", + "path": [] + }, + { + "property": "boxShadow", + "token": "tokens.colorStrokeFocus2", + "path": [] + }, + { + "property": "borderTopColor", + "token": "tokens.colorStrokeFocus2", + "path": [] + }, + { + "property": "borderRightColor", + "token": "tokens.colorStrokeFocus2", + "path": [] + }, + { + "property": "borderBottomColor", + "token": "tokens.colorStrokeFocus2", + "path": [] + }, + { + "property": "borderLeftColor", + "token": "tokens.colorStrokeFocus2", + "path": [] + } + ] + }, + "'@supports (-moz-appearance:button)'": { + "tokens": [ + { + "property": "boxShadow", + "token": "tokens.shadow2", + "path": [] + }, + { + "property": "boxShadow", + "token": "tokens.colorStrokeFocus2", + "path": [] + }, + { + "property": "boxShadow", + "token": "tokens.strokeWidthThick", + "path": [] + }, + { + "property": "boxShadow", + "token": "tokens.colorNeutralForegroundOnBrand", + "path": [] + }, + { + "property": "boxShadow", + "token": "tokens.shadow2", + "path": [] + }, + { + "property": "boxShadow", + "token": "tokens.colorStrokeFocus2", + "path": [] + } + ] + } + }, + "assignedVariables": ["rootFocusStyles"] + }, "small": { "tokens": [], "nested": { @@ -1145,6 +1318,26 @@ "useRootExpandedStyles": { "outline": { "tokens": [ + { + "property": "borderTopColor", + "token": "tokens.colorNeutralStroke1Selected", + "path": ["borderTopColor"] + }, + { + "property": "borderRightColor", + "token": "tokens.colorNeutralStroke1Selected", + "path": ["borderRightColor"] + }, + { + "property": "borderBottomColor", + "token": "tokens.colorNeutralStroke1Selected", + "path": ["borderBottomColor"] + }, + { + "property": "borderLeftColor", + "token": "tokens.colorNeutralStroke1Selected", + "path": ["borderLeftColor"] + }, { "property": "color", "token": "tokens.colorNeutralForeground1Selected", @@ -1170,6 +1363,26 @@ "token": "tokens.colorNeutralBackground1Selected", "path": ["backgroundColor"] }, + { + "property": "borderTopColor", + "token": "tokens.colorNeutralStroke1Selected", + "path": ["borderTopColor"] + }, + { + "property": "borderRightColor", + "token": "tokens.colorNeutralStroke1Selected", + "path": ["borderRightColor"] + }, + { + "property": "borderBottomColor", + "token": "tokens.colorNeutralStroke1Selected", + "path": ["borderBottomColor"] + }, + { + "property": "borderLeftColor", + "token": "tokens.colorNeutralStroke1Selected", + "path": ["borderLeftColor"] + }, { "property": "color", "token": "tokens.colorNeutralForeground1Selected", @@ -1536,6 +1749,26 @@ "token": "tokens.colorNeutralBackground1Selected", "path": ["backgroundColor"] }, + { + "property": "borderTopColor", + "token": "tokens.colorNeutralStroke1", + "path": ["borderTopColor"] + }, + { + "property": "borderRightColor", + "token": "tokens.colorNeutralStroke1", + "path": ["borderRightColor"] + }, + { + "property": "borderBottomColor", + "token": "tokens.colorNeutralStroke1", + "path": ["borderBottomColor"] + }, + { + "property": "borderLeftColor", + "token": "tokens.colorNeutralStroke1", + "path": ["borderLeftColor"] + }, { "property": "color", "token": "tokens.colorNeutralForeground1Selected", @@ -1550,6 +1783,26 @@ "token": "tokens.colorNeutralBackground1Hover", "path": [] }, + { + "property": "borderTopColor", + "token": "tokens.colorNeutralStroke1Hover", + "path": [] + }, + { + "property": "borderRightColor", + "token": "tokens.colorNeutralStroke1Hover", + "path": [] + }, + { + "property": "borderBottomColor", + "token": "tokens.colorNeutralStroke1Hover", + "path": [] + }, + { + "property": "borderLeftColor", + "token": "tokens.colorNeutralStroke1Hover", + "path": [] + }, { "property": "color", "token": "tokens.colorNeutralForeground1Hover", @@ -1564,6 +1817,26 @@ "token": "tokens.colorNeutralBackground1Pressed", "path": [] }, + { + "property": "borderTopColor", + "token": "tokens.colorNeutralStroke1Pressed", + "path": [] + }, + { + "property": "borderRightColor", + "token": "tokens.colorNeutralStroke1Pressed", + "path": [] + }, + { + "property": "borderBottomColor", + "token": "tokens.colorNeutralStroke1Pressed", + "path": [] + }, + { + "property": "borderLeftColor", + "token": "tokens.colorNeutralStroke1Pressed", + "path": [] + }, { "property": "color", "token": "tokens.colorNeutralForeground1Pressed", @@ -1580,6 +1853,26 @@ "property": "backgroundColor", "token": "tokens.colorTransparentBackgroundSelected", "path": ["backgroundColor"] + }, + { + "property": "borderTopColor", + "token": "tokens.colorNeutralStroke1", + "path": ["borderTopColor"] + }, + { + "property": "borderRightColor", + "token": "tokens.colorNeutralStroke1", + "path": ["borderRightColor"] + }, + { + "property": "borderBottomColor", + "token": "tokens.colorNeutralStroke1", + "path": ["borderBottomColor"] + }, + { + "property": "borderLeftColor", + "token": "tokens.colorNeutralStroke1", + "path": ["borderLeftColor"] } ], "nested": { @@ -1748,6 +2041,26 @@ "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", @@ -1762,6 +2075,26 @@ "token": "tokens.colorNeutralBackgroundDisabled", "path": [] }, + { + "property": "borderTopColor", + "token": "tokens.colorNeutralStrokeDisabled", + "path": [] + }, + { + "property": "borderRightColor", + "token": "tokens.colorNeutralStrokeDisabled", + "path": [] + }, + { + "property": "borderBottomColor", + "token": "tokens.colorNeutralStrokeDisabled", + "path": [] + }, + { + "property": "borderLeftColor", + "token": "tokens.colorNeutralStrokeDisabled", + "path": [] + }, { "property": "color", "token": "tokens.colorNeutralForegroundDisabled", @@ -1776,6 +2109,26 @@ "token": "tokens.colorNeutralBackgroundDisabled", "path": [] }, + { + "property": "borderTopColor", + "token": "tokens.colorNeutralStrokeDisabled", + "path": [] + }, + { + "property": "borderRightColor", + "token": "tokens.colorNeutralStrokeDisabled", + "path": [] + }, + { + "property": "borderBottomColor", + "token": "tokens.colorNeutralStrokeDisabled", + "path": [] + }, + { + "property": "borderLeftColor", + "token": "tokens.colorNeutralStrokeDisabled", + "path": [] + }, { "property": "color", "token": "tokens.colorNeutralForegroundDisabled", diff --git a/packages/react-components/token-analyzer-preview/library/src/index.ts b/packages/react-components/token-analyzer-preview/library/src/index.ts index 072f1785bbded..c67bde652515c 100644 --- a/packages/react-components/token-analyzer-preview/library/src/index.ts +++ b/packages/react-components/token-analyzer-preview/library/src/index.ts @@ -49,7 +49,7 @@ async function analyzeProjectStyles( if (outputFile) { await measureAsync('write output file', async () => { - const formatted = format(JSON.stringify(results, null, 2), { + const formatted = format(JSON.stringify(sortObjectByKeys(results), null, 2), { parser: 'json', printWidth: 120, tabWidth: 2, @@ -69,6 +69,21 @@ async function analyzeProjectStyles( } } +/** + * Sorts an object by its keys alphabetically + * + * @param obj Object to sort + * @returns New object with the same properties, sorted by keys + */ +function sortObjectByKeys(obj: Record): Record { + return Object.keys(obj) + .sort() + .reduce((sorted: Record, key: string) => { + sorted[key] = obj[key]; + return sorted; + }, {}); +} + function countTokens(analysis: FileAnalysis): number { let count = 0; Object.values(analysis.styles).forEach(_value => {