From b0d6828e24d5bdc54b20693f9c2f3c5227da1963 Mon Sep 17 00:00:00 2001 From: Donald Labaj Date: Wed, 21 Jan 2026 15:45:38 -0500 Subject: [PATCH 01/10] feat(API): Added CSS API endpoint for react tokens. Added code to get and create the index at build time. Updated css.ts to retrieve code from apiIndex.json added unit tests for extracting the patternfly css. --- .../api/[version]/[section]/[page]/css.ts | 39 ++ src/pages/api/index.ts | 36 ++ .../__tests__/extractReactTokens.test.ts | 498 ++++++++++++++++++ src/utils/apiIndex/generate.ts | 28 + src/utils/apiIndex/get.ts | 23 + src/utils/extractReactTokens.ts | 119 +++++ 6 files changed, 743 insertions(+) create mode 100644 src/pages/api/[version]/[section]/[page]/css.ts create mode 100644 src/utils/__tests__/extractReactTokens.test.ts create mode 100644 src/utils/extractReactTokens.ts diff --git a/src/pages/api/[version]/[section]/[page]/css.ts b/src/pages/api/[version]/[section]/[page]/css.ts new file mode 100644 index 0000000..e6c6faf --- /dev/null +++ b/src/pages/api/[version]/[section]/[page]/css.ts @@ -0,0 +1,39 @@ +import type { APIRoute } from 'astro' +import { createJsonResponse, createIndexKey } from '../../../../../utils/apiHelpers' +import { fetchApiIndex } from '../../../../../utils/apiIndex/fetch' + +export const prerender = false + +export const GET: APIRoute = async ({ params, url }) => { + const { version, section, page } = params + + if (!version || !section || !page) { + return createJsonResponse( + { error: 'Version, section, and page parameters are required' }, + 400, + ) + } + + try { + const index = await fetchApiIndex(url) + const pageKey = createIndexKey(version, section, page) + const cssTokens = index.css[pageKey] || [] + + if (cssTokens.length === 0) { + return createJsonResponse( + { + error: `No CSS tokens found for page '${page}' in section '${section}' for version '${version}'. CSS tokens are only available for content with a cssPrefix in the front matter.`, + }, + 404, + ) + } + + return createJsonResponse(cssTokens) + } catch (error) { + const details = error instanceof Error ? error.message : String(error) + return createJsonResponse( + { error: 'Failed to load API index', details }, + 500, + ) + } +} diff --git a/src/pages/api/index.ts b/src/pages/api/index.ts index 82d18ce..757a51d 100644 --- a/src/pages/api/index.ts +++ b/src/pages/api/index.ts @@ -205,6 +205,42 @@ export const GET: APIRoute = async () => ], }, }, + { + path: '/api/{version}/{section}/{page}/css', + method: 'GET', + description: 'Get CSS tokens for a specific page', + parameters: [ + { + name: 'version', + in: 'path', + required: true, + type: 'string', + example: 'v6', + }, + { + name: 'section', + in: 'path', + required: true, + type: 'string', + example: 'components', + }, + { + name: 'page', + in: 'path', + required: true, + type: 'string', + example: 'alert', + }, + ], + returns: { + type: 'array', + items: 'object', + description: 'Array of CSS token objects with tokenName, value, and variableName', + example: [ + { tokenName: 'c_alert__Background', value: '#000000', variableName: 'c_alert__Background' }, + ], + }, + }, { path: '/api/{version}/{section}/{page}/{tab}', method: 'GET', diff --git a/src/utils/__tests__/extractReactTokens.test.ts b/src/utils/__tests__/extractReactTokens.test.ts new file mode 100644 index 0000000..eb395a9 --- /dev/null +++ b/src/utils/__tests__/extractReactTokens.test.ts @@ -0,0 +1,498 @@ +import { readdir, readFile } from 'fs/promises' +import { existsSync } from 'fs' +import { join } from 'path' +import { extractReactTokens } from '../extractReactTokens' + +// Mock fs/promises +jest.mock('fs/promises', () => ({ + readdir: jest.fn(), + readFile: jest.fn(), +})) + +// Mock fs +jest.mock('fs', () => ({ + existsSync: jest.fn(), +})) + +// Mock path +jest.mock('path', () => ({ + join: jest.fn((...args) => args.join('/')), +})) + +// Mock process.cwd +const originalCwd = process.cwd +beforeAll(() => { + process.cwd = jest.fn(() => '/test/project') +}) + +afterAll(() => { + process.cwd = originalCwd +}) + +describe('extractReactTokens', () => { + beforeEach(() => { + jest.clearAllMocks() + // Reset console methods + jest.spyOn(console, 'error').mockImplementation(() => {}) + jest.spyOn(console, 'warn').mockImplementation(() => {}) + }) + + afterEach(() => { + jest.restoreAllMocks() + }) + + describe('CSS prefix to token prefix conversion', () => { + it('converts single CSS prefix correctly', async () => { + ;(existsSync as jest.Mock).mockReturnValue(true) + ;(readdir as jest.Mock).mockResolvedValue([]) + + await extractReactTokens('pf-v6-c-accordion') + + expect(join).toHaveBeenCalledWith( + '/test/project', + 'node_modules', + '@patternfly', + 'react-tokens', + 'dist', + 'esm', + ) + }) + + it('converts array of CSS prefixes correctly', async () => { + ;(existsSync as jest.Mock).mockReturnValue(true) + ;(readdir as jest.Mock).mockResolvedValue([]) + + await extractReactTokens(['pf-v6-c-accordion', 'pf-v6-c-button']) + + expect(join).toHaveBeenCalledWith( + '/test/project', + 'node_modules', + '@patternfly', + 'react-tokens', + 'dist', + 'esm', + ) + }) + + it('handles CSS prefix without pf-v6- prefix', async () => { + ;(existsSync as jest.Mock).mockReturnValue(true) + ;(readdir as jest.Mock).mockResolvedValue([]) + + await extractReactTokens('c-accordion') + + expect(join).toHaveBeenCalled() + }) + }) + + describe('directory existence check', () => { + it('returns empty array when tokens directory does not exist', async () => { + ;(existsSync as jest.Mock).mockReturnValue(false) + + const result = await extractReactTokens('pf-v6-c-accordion') + + expect(result).toEqual([]) + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining('Tokens directory not found'), + ) + expect(readdir).not.toHaveBeenCalled() + }) + + it('returns empty array when tokens directory exists', async () => { + ;(existsSync as jest.Mock).mockReturnValue(true) + ;(readdir as jest.Mock).mockResolvedValue([]) + + const result = await extractReactTokens('pf-v6-c-accordion') + + expect(result).toEqual([]) + expect(readdir).toHaveBeenCalled() + }) + }) + + describe('file filtering', () => { + it('filters out non-JS files', async () => { + ;(existsSync as jest.Mock).mockReturnValue(true) + ;(readdir as jest.Mock).mockResolvedValue([ + 'c_accordion__toggle_FontFamily.js', + 'c_accordion.ts', + 'c_accordion.json', + 'c_accordion.css', + ]) + ;(readFile as jest.Mock).mockResolvedValue( + 'export const token = { "name": "test", "value": "test", "var": "--test" };', + ) + + const result = await extractReactTokens('pf-v6-c-accordion') + + expect(readFile).toHaveBeenCalledTimes(1) + expect(readFile).toHaveBeenCalledWith( + expect.stringContaining('c_accordion__toggle_FontFamily.js'), + 'utf8', + ) + }) + + it('filters out componentIndex.js', async () => { + ;(existsSync as jest.Mock).mockReturnValue(true) + ;(readdir as jest.Mock).mockResolvedValue([ + 'componentIndex.js', + 'c_accordion__toggle_FontFamily.js', + ]) + ;(readFile as jest.Mock).mockResolvedValue( + 'export const token = { "name": "test", "value": "test", "var": "--test" };', + ) + + const result = await extractReactTokens('pf-v6-c-accordion') + + expect(readFile).toHaveBeenCalledTimes(1) + expect(readFile).not.toHaveBeenCalledWith( + expect.stringContaining('componentIndex.js'), + expect.anything(), + ) + }) + + it('filters out main component file (e.g., c_accordion.js)', async () => { + ;(existsSync as jest.Mock).mockReturnValue(true) + ;(readdir as jest.Mock).mockResolvedValue([ + 'c_accordion.js', + 'c_accordion__toggle_FontFamily.js', + 'c_accordion__header_BackgroundColor.js', + ]) + ;(readFile as jest.Mock).mockResolvedValue( + 'export const token = { "name": "test", "value": "test", "var": "--test" };', + ) + + const result = await extractReactTokens('pf-v6-c-accordion') + + expect(readFile).toHaveBeenCalledTimes(2) + expect(readFile).not.toHaveBeenCalledWith( + expect.stringContaining('c_accordion.js'), + expect.anything(), + ) + expect(readFile).toHaveBeenCalledWith( + expect.stringContaining('c_accordion__toggle_FontFamily.js'), + 'utf8', + ) + expect(readFile).toHaveBeenCalledWith( + expect.stringContaining('c_accordion__header_BackgroundColor.js'), + 'utf8', + ) + }) + + it('includes files that start with token prefix but are not the main component file', async () => { + ;(existsSync as jest.Mock).mockReturnValue(true) + ;(readdir as jest.Mock).mockResolvedValue([ + 'c_accordion__toggle_FontFamily.js', + 'c_accordion__header_BackgroundColor.js', + 'c_accordion__section_PaddingTop.js', + ]) + ;(readFile as jest.Mock).mockResolvedValue( + 'export const token = { "name": "test", "value": "test", "var": "--test" };', + ) + + const result = await extractReactTokens('pf-v6-c-accordion') + + expect(readFile).toHaveBeenCalledTimes(3) + }) + + it('handles multiple prefixes and matches files for any prefix', async () => { + ;(existsSync as jest.Mock).mockReturnValue(true) + ;(readdir as jest.Mock).mockResolvedValue([ + 'c_accordion__toggle_FontFamily.js', + 'c_button__primary_BackgroundColor.js', + 'c_other_component.js', + ]) + ;(readFile as jest.Mock).mockResolvedValue( + 'export const token = { "name": "test", "value": "test", "var": "--test" };', + ) + + const result = await extractReactTokens([ + 'pf-v6-c-accordion', + 'pf-v6-c-button', + ]) + + expect(readFile).toHaveBeenCalledTimes(2) + expect(readFile).toHaveBeenCalledWith( + expect.stringContaining('c_accordion__toggle_FontFamily.js'), + 'utf8', + ) + expect(readFile).toHaveBeenCalledWith( + expect.stringContaining('c_button__primary_BackgroundColor.js'), + 'utf8', + ) + }) + }) + + describe('token extraction from files', () => { + it('extracts token object from valid file content', async () => { + ;(existsSync as jest.Mock).mockReturnValue(true) + ;(readdir as jest.Mock).mockResolvedValue([ + 'c_accordion__toggle_FontFamily.js', + ]) + ;(readFile as jest.Mock).mockResolvedValue( + 'export const c_accordion_toggle_FontFamily = { "name": "c-accordion-toggle-FontFamily", "value": "1rem", "var": "--pf-v6-c-accordion--toggle--FontFamily"\n};', + ) + + const result = await extractReactTokens('pf-v6-c-accordion') + + expect(result).toEqual([ + { + name: 'c-accordion-toggle-FontFamily', + value: '1rem', + var: '--pf-v6-c-accordion--toggle--FontFamily', + }, + ]) + }) + + it('extracts multiple token objects from multiple files', async () => { + ;(existsSync as jest.Mock).mockReturnValue(true) + ;(readdir as jest.Mock).mockResolvedValue([ + 'c_accordion__toggle_FontFamily.js', + 'c_accordion__header_BackgroundColor.js', + ]) + ;(readFile as jest.Mock) + .mockResolvedValueOnce( + 'export const token1 = { "name": "c-accordion-toggle-FontFamily", "value": "1rem", "var": "--pf-v6-c-accordion--toggle--FontFamily"\n};', + ) + .mockResolvedValueOnce( + 'export const token2 = { "name": "c-accordion-header-BackgroundColor", "value": "#fff", "var": "--pf-v6-c-accordion--header--BackgroundColor"\n};', + ) + + const result = await extractReactTokens('pf-v6-c-accordion') + + expect(result).toHaveLength(2) + expect(result).toEqual([ + { + name: 'c-accordion-header-BackgroundColor', + value: '#fff', + var: '--pf-v6-c-accordion--header--BackgroundColor', + }, + { + name: 'c-accordion-toggle-FontFamily', + value: '1rem', + var: '--pf-v6-c-accordion--toggle--FontFamily', + }, + ]) + }) + + it('handles file content with multiline object definition', async () => { + ;(existsSync as jest.Mock).mockReturnValue(true) + ;(readdir as jest.Mock).mockResolvedValue([ + 'c_accordion__toggle_FontFamily.js', + ]) + ;(readFile as jest.Mock).mockResolvedValue(`export const token = { + "name": "c-accordion-toggle-FontFamily", + "value": "1rem", + "var": "--pf-v6-c-accordion--toggle--FontFamily" +};`) + + const result = await extractReactTokens('pf-v6-c-accordion') + + expect(result).toEqual([ + { + name: 'c-accordion-toggle-FontFamily', + value: '1rem', + var: '--pf-v6-c-accordion--toggle--FontFamily', + }, + ]) + }) + + it('handles file content with whitespace and comments', async () => { + ;(existsSync as jest.Mock).mockReturnValue(true) + ;(readdir as jest.Mock).mockResolvedValue([ + 'c_accordion__toggle_FontFamily.js', + ]) + ;(readFile as jest.Mock).mockResolvedValue( + '// Some comment\nexport const token = { "name": "test", "value": "test", "var": "--test"\n};// Another comment', + ) + + const result = await extractReactTokens('pf-v6-c-accordion') + + expect(result).toEqual([ + { + name: 'test', + value: 'test', + var: '--test', + }, + ]) + }) + + it('skips files that do not match the export pattern', async () => { + ;(existsSync as jest.Mock).mockReturnValue(true) + ;(readdir as jest.Mock).mockResolvedValue([ + 'c_accordion__toggle_FontFamily.js', + 'c_accordion__header_BackgroundColor.js', + ]) + ;(readFile as jest.Mock) + .mockResolvedValueOnce('const token = { "name": "test" };') // No export + .mockResolvedValueOnce( + 'export const token = { "name": "test", "value": "test", "var": "--test"\n};', + ) + + const result = await extractReactTokens('pf-v6-c-accordion') + + expect(result).toHaveLength(1) + expect(result[0].name).toBe('test') + }) + + it('validates token object has required properties', async () => { + ;(existsSync as jest.Mock).mockReturnValue(true) + ;(readdir as jest.Mock).mockResolvedValue([ + 'c_accordion__toggle_FontFamily.js', + ]) + ;(readFile as jest.Mock).mockResolvedValue( + 'export const token = { "name": "test" };', // Missing value and var + ) + + const result = await extractReactTokens('pf-v6-c-accordion') + + expect(result).toEqual([]) + }) + + it('validates token object properties are strings', async () => { + ;(existsSync as jest.Mock).mockReturnValue(true) + ;(readdir as jest.Mock).mockResolvedValue([ + 'c_accordion__toggle_FontFamily.js', + ]) + ;(readFile as jest.Mock).mockResolvedValue( + 'export const token = { "name": 123, "value": "test", "var": "--test" };', // name is not a string + ) + + const result = await extractReactTokens('pf-v6-c-accordion') + + expect(result).toEqual([]) + }) + }) + + describe('error handling', () => { + it('handles file read errors gracefully', async () => { + ;(existsSync as jest.Mock).mockReturnValue(true) + ;(readdir as jest.Mock).mockResolvedValue([ + 'c_accordion__toggle_FontFamily.js', + 'c_accordion__header_BackgroundColor.js', + ]) + ;(readFile as jest.Mock) + .mockRejectedValueOnce(new Error('Permission denied')) + .mockResolvedValueOnce( + 'export const token = { "name": "test", "value": "test", "var": "--test"\n};', + ) + + const result = await extractReactTokens('pf-v6-c-accordion') + + expect(console.warn).toHaveBeenCalledWith( + expect.stringContaining('Failed to read file'), + expect.any(Error), + ) + expect(result).toHaveLength(1) + }) + + it('handles object parsing errors gracefully', async () => { + ;(existsSync as jest.Mock).mockReturnValue(true) + ;(readdir as jest.Mock).mockResolvedValue([ + 'c_accordion__toggle_FontFamily.js', + ]) + ;(readFile as jest.Mock).mockResolvedValue( + 'export const token = { invalid syntax\n};', // Invalid JavaScript + ) + + const result = await extractReactTokens('pf-v6-c-accordion') + + expect(console.warn).toHaveBeenCalledWith( + expect.stringContaining('Failed to parse object'), + expect.any(Error), + ) + expect(result).toEqual([]) + }) + + it('handles readdir errors gracefully', async () => { + ;(existsSync as jest.Mock).mockReturnValue(true) + ;(readdir as jest.Mock).mockRejectedValue(new Error('Directory read failed')) + + await expect(extractReactTokens('pf-v6-c-accordion')).rejects.toThrow( + 'Directory read failed', + ) + }) + }) + + describe('sorting', () => { + it('sorts tokens by name alphabetically', async () => { + ;(existsSync as jest.Mock).mockReturnValue(true) + ;(readdir as jest.Mock).mockResolvedValue([ + 'c_accordion__z_token.js', + 'c_accordion__a_token.js', + 'c_accordion__m_token.js', + ]) + ;(readFile as jest.Mock) + .mockResolvedValueOnce( + 'export const token1 = { "name": "z-token", "value": "test", "var": "--test"\n};', + ) + .mockResolvedValueOnce( + 'export const token2 = { "name": "a-token", "value": "test", "var": "--test"\n};', + ) + .mockResolvedValueOnce( + 'export const token3 = { "name": "m-token", "value": "test", "var": "--test"\n};', + ) + + const result = await extractReactTokens('pf-v6-c-accordion') + + expect(result).toEqual([ + { name: 'a-token', value: 'test', var: '--test' }, + { name: 'm-token', value: 'test', var: '--test' }, + { name: 'z-token', value: 'test', var: '--test' }, + ]) + }) + }) + + describe('edge cases', () => { + it('handles empty file list', async () => { + ;(existsSync as jest.Mock).mockReturnValue(true) + ;(readdir as jest.Mock).mockResolvedValue([]) + + const result = await extractReactTokens('pf-v6-c-accordion') + + expect(result).toEqual([]) + }) + + it('handles files with no matching prefix', async () => { + ;(existsSync as jest.Mock).mockReturnValue(true) + ;(readdir as jest.Mock).mockResolvedValue([ + 'other_component__token.js', + 'unrelated_file.js', + ]) + + const result = await extractReactTokens('pf-v6-c-accordion') + + expect(result).toEqual([]) + expect(readFile).not.toHaveBeenCalled() + }) + + it('handles CSS prefix with multiple hyphens', async () => { + ;(existsSync as jest.Mock).mockReturnValue(true) + ;(readdir as jest.Mock).mockResolvedValue([ + 'c_data_list__item_row_BackgroundColor.js', + ]) + ;(readFile as jest.Mock).mockResolvedValue( + 'export const token = { "name": "test", "value": "test", "var": "--test"\n};', + ) + + const result = await extractReactTokens('pf-v6-c-data-list') + + expect(readFile).toHaveBeenCalled() + expect(result).toHaveLength(1) + }) + + it('handles file content with multiple export statements', async () => { + ;(existsSync as jest.Mock).mockReturnValue(true) + ;(readdir as jest.Mock).mockResolvedValue([ + 'c_accordion__toggle_FontFamily.js', + ]) + ;(readFile as jest.Mock).mockResolvedValue( + 'export const token1 = { "name": "first", "value": "test", "var": "--test"\n};export const token2 = { "name": "second", "value": "test", "var": "--test"\n};', + ) + + const result = await extractReactTokens('pf-v6-c-accordion') + + // Should only extract the first matching export + expect(result).toHaveLength(1) + expect(result[0].name).toBe('first') + }) + }) +}) diff --git a/src/utils/apiIndex/generate.ts b/src/utils/apiIndex/generate.ts index 19cc10e..bfbde0e 100644 --- a/src/utils/apiIndex/generate.ts +++ b/src/utils/apiIndex/generate.ts @@ -8,6 +8,7 @@ import { kebabCase, addDemosOrDeprecated } from '../index' import { getDefaultTabForApi } from '../packageUtils' import { getOutputDir } from '../getOutputDir' import { addSubsection } from '../case' +import { extractReactTokens } from '../extractReactTokens' const SOURCE_ORDER: Record = { react: 1, @@ -60,6 +61,8 @@ export interface ApiIndex { * (e.g., { 'v6::components::alert::react': [{exampleName: 'AlertDefault', title: 'Default alert'}] }) */ examples: Record + /** CSS token objects by version::section::page (e.g., { 'v6::components::accordion': [{name: '--pf-v6-c-accordion--...', value: '...', var: '...'}] }) */ + css: Record> } /** @@ -117,6 +120,7 @@ export async function generateApiIndex(): Promise { pages: {}, tabs: {}, examples: {}, + css: {}, } // Get all versions @@ -145,6 +149,8 @@ export async function generateApiIndex(): Promise { const sectionPages: Record> = {} const pageTabs: Record> = {} const tabExamples: Record = {} + const pageCss: Record> = {} + const pageCssPrefixes: Record = {} flatEntries.forEach((entry: any) => { const { section, subsection, id } = entry.data @@ -185,8 +191,25 @@ export async function generateApiIndex(): Promise { if (examplesWithTitles.length > 0) { tabExamples[exampleKey] = examplesWithTitles } + + // Collect CSS prefixes for pages - we'll extract tokens later + if (entry.data.cssPrefix && !pageCssPrefixes[pageKey]) { + pageCssPrefixes[pageKey] = entry.data.cssPrefix + } }) + // Extract CSS tokens for pages that have cssPrefix + for (const [pageKey, cssPrefix] of Object.entries(pageCssPrefixes)) { + try { + const tokens = await extractReactTokens(cssPrefix) + if (tokens.length > 0) { + pageCss[pageKey] = tokens + } + } catch (error) { + console.warn(`Failed to extract CSS tokens for ${pageKey}:`, error) + } + } + // Convert sets to sorted arrays // Sections are now always flat strings (subsections are in page names) index.sections[version] = Array.from(sections).sort() @@ -202,6 +225,11 @@ export async function generateApiIndex(): Promise { Object.entries(tabExamples).forEach(([key, examples]) => { index.examples[key] = examples }) + + // Add CSS token objects to index + Object.entries(pageCss).forEach(([key, tokens]) => { + index.css[key] = tokens + }) } return index diff --git a/src/utils/apiIndex/get.ts b/src/utils/apiIndex/get.ts index a20c3b8..e35a662 100644 --- a/src/utils/apiIndex/get.ts +++ b/src/utils/apiIndex/get.ts @@ -27,6 +27,10 @@ export async function getApiIndex(): Promise { throw new Error('Invalid API index structure: missing or invalid "examples" object') } + if (!parsed.css || typeof parsed.css !== 'object') { + throw new Error('Invalid API index structure: missing or invalid "css" object') + } + return parsed as ApiIndex } catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') { @@ -117,3 +121,22 @@ export async function getExamples( const key = createIndexKey(version, section, page, tab) return index.examples[key] || [] } + +/** + * Gets CSS token objects for a specific page + * + * @param version - The documentation version (e.g., 'v6') + * @param section - The section name (e.g., 'components') + * @param page - The page slug (e.g., 'accordion') + * @returns Promise resolving to array of token objects, or empty array if not found + */ +export async function getCssTokens( + version: string, + section: string, + page: string, +): Promise> { + const index = await getApiIndex() + const { createIndexKey } = await import('../apiHelpers') + const key = createIndexKey(version, section, page) + return index.css[key] || [] +} diff --git a/src/utils/extractReactTokens.ts b/src/utils/extractReactTokens.ts new file mode 100644 index 0000000..305708f --- /dev/null +++ b/src/utils/extractReactTokens.ts @@ -0,0 +1,119 @@ +import { readdir, readFile } from 'fs/promises' +import { join } from 'path' +import { existsSync } from 'fs' + +/** + * Converts a CSS prefix (e.g., "pf-v6-c-accordion") to a token prefix (e.g., "c_accordion") + * + * @param cssPrefix - The CSS prefix from front matter + * @returns The token prefix used in file names + */ +function cssPrefixToTokenPrefix(cssPrefix: string): string { + // Remove "pf-v6-" prefix and replace hyphens with underscores to match the tokens. + return cssPrefix.replace(/^pf-v6-/, '').replace(/-+/g, '_') +} + +/** + * Extracts all token objects from @patternfly/react-tokens that match a given CSS prefix + * + * @param cssPrefix - The CSS prefix (e.g., "pf-v6-c-accordion") + * @returns Array of token objects with name, value, and var properties + */ +export async function extractReactTokens( + cssPrefix: string | string[], +): Promise> { + // Handle both single prefix and array of prefixes to support the subcomponents. + const prefixes = Array.isArray(cssPrefix) ? cssPrefix : [cssPrefix] + const tokenPrefixes = prefixes.map(cssPrefixToTokenPrefix) + + // Path to the react-tokens esm directory. + const tokensDir = join( + process.cwd(), + 'node_modules', + '@patternfly', + 'react-tokens', + 'dist', + 'esm', + ) + + if (!existsSync(tokensDir)) { + console.error(`Tokens directory not found: ${tokensDir}`) + return [] + } + + // Get all files in the directory + const files = await readdir(tokensDir) + + // Filter for .js files that match any of the token prefixes + // Exclude componentIndex.js and main component files (like c_accordion.js without underscores after the prefix) + const matchingFiles = files.filter((file) => { + if (!file.endsWith('.js') || file === 'componentIndex.js') { + return false + } + // Check if file starts with any of the token prefixes + // We want individual token files (e.g., c_accordion__toggle_FontFamily.js) + // but not the main component index file (e.g., c_accordion.js) + return tokenPrefixes.some((prefix) => { + if (file === `${prefix}.js`) { + // This is the main component file, skip it + return false + } + return file.startsWith(prefix) + }) + }) + + // Import and extract objects from each matching file + const tokenObjects: Array<{ name: string; value: string; var: string }> = [] + + for (const file of matchingFiles) { + try { + const filePath = join(tokensDir, file) + const fileContent = await readFile(filePath, 'utf8') + + // Extract the exported object using regex + // Pattern: export const variableName = { "name": "...", "value": "...", "var": "..." }; + // Use non-greedy match to get just the first exported const object + const objectMatch = fileContent.match( + /export const \w+ = \{[\s\S]*?\n\};/, + ) + + if (objectMatch) { + // Parse the object string to extract the JSON-like object + const objectContent = objectMatch[0] + .replace(/export const \w+ = /, '') + .replace(/;$/, '') + + try { + // Use Function constructor for safe evaluation + // The object content is valid JavaScript, so we can evaluate it + const tokenObject = new Function(`return ${objectContent}`)() as { + name: string + value: string + var: string + } + + if ( + tokenObject && + typeof tokenObject === 'object' && + typeof tokenObject.name === 'string' && + typeof tokenObject.value === 'string' && + typeof tokenObject.var === 'string' + ) { + tokenObjects.push({ + name: tokenObject.name, + value: tokenObject.value, + var: tokenObject.var, + }) + } + } catch (evalError) { + console.warn(`Failed to parse object from ${file}:`, evalError) + } + } + } catch (error) { + console.warn(`Failed to read file ${file}:`, error) + } + } + + // Sort by name for consistent ordering + return tokenObjects.sort((a, b) => a.name.localeCompare(b.name)) +} From 5c701cdcca191cb33f284df4a3ce5f0139bb4a78 Mon Sep 17 00:00:00 2001 From: Donald Labaj Date: Thu, 22 Jan 2026 14:10:00 -0500 Subject: [PATCH 02/10] Added endpoint tests. --- .../[version]/[section]/[page]/css.test.ts | 236 ++++++++++++++++++ 1 file changed, 236 insertions(+) create mode 100644 src/__tests__/pages/api/__tests__/[version]/[section]/[page]/css.test.ts diff --git a/src/__tests__/pages/api/__tests__/[version]/[section]/[page]/css.test.ts b/src/__tests__/pages/api/__tests__/[version]/[section]/[page]/css.test.ts new file mode 100644 index 0000000..bb835d6 --- /dev/null +++ b/src/__tests__/pages/api/__tests__/[version]/[section]/[page]/css.test.ts @@ -0,0 +1,236 @@ +import { GET } from '../../../../../../../pages/api/[version]/[section]/[page]/css' + +/** + * Mock fetchApiIndex to return API index with CSS tokens + */ +jest.mock('../../../../../../../utils/apiIndex/fetch', () => ({ + fetchApiIndex: jest.fn().mockResolvedValue({ + versions: ['v6'], + sections: { + v6: ['components'], + }, + pages: { + 'v6::components': ['alert', 'button'], + }, + tabs: { + 'v6::components::alert': ['react', 'html'], + 'v6::components::button': ['react'], + }, + css: { + 'v6::components::alert': [ + { + name: '--pf-v6-c-alert--BackgroundColor', + value: '#ffffff', + description: 'Alert background color', + }, + { + name: '--pf-v6-c-alert--Color', + value: '#151515', + description: 'Alert text color', + }, + ], + 'v6::components::button': [ + { + name: '--pf-v6-c-button--BackgroundColor', + value: '#0066cc', + description: 'Button background color', + }, + ], + }, + }), +})) + +beforeEach(() => { + jest.clearAllMocks() +}) + +it('returns CSS tokens for a valid page', async () => { + const response = await GET({ + params: { + version: 'v6', + section: 'components', + page: 'alert', + }, + url: new URL('http://localhost/api/v6/components/alert/css'), + } as any) + const body = await response.json() + + expect(response.status).toBe(200) + expect(response.headers.get('Content-Type')).toBe('application/json; charset=utf-8') + expect(Array.isArray(body)).toBe(true) + expect(body).toHaveLength(2) + expect(body[0]).toHaveProperty('name') + expect(body[0]).toHaveProperty('value') + expect(body[0]).toHaveProperty('description') + expect(body[0].name).toBe('--pf-v6-c-alert--BackgroundColor') + expect(body[0].value).toBe('#ffffff') +}) + +it('returns CSS tokens for different pages', async () => { + const buttonResponse = await GET({ + params: { + version: 'v6', + section: 'components', + page: 'button', + }, + url: new URL('http://localhost/api/v6/components/button/css'), + } as any) + const buttonBody = await buttonResponse.json() + + expect(buttonResponse.status).toBe(200) + expect(Array.isArray(buttonBody)).toBe(true) + expect(buttonBody).toHaveLength(1) + expect(buttonBody[0].name).toBe('--pf-v6-c-button--BackgroundColor') +}) + +it('returns 404 error when no CSS tokens are found', async () => { + const response = await GET({ + params: { + version: 'v6', + section: 'components', + page: 'nonexistent', + }, + url: new URL('http://localhost/api/v6/components/nonexistent/css'), + } as any) + const body = await response.json() + + expect(response.status).toBe(404) + expect(body).toHaveProperty('error') + expect(body.error).toContain('No CSS tokens found') + expect(body.error).toContain('nonexistent') + expect(body.error).toContain('components') + expect(body.error).toContain('v6') + expect(body.error).toContain('cssPrefix') +}) + +it('returns 400 error when version parameter is missing', async () => { + const response = await GET({ + params: { + section: 'components', + page: 'alert', + }, + url: new URL('http://localhost/api/components/alert/css'), + } as any) + const body = await response.json() + + expect(response.status).toBe(400) + expect(body).toHaveProperty('error') + expect(body.error).toContain('Version, section, and page parameters are required') +}) + +it('returns 400 error when section parameter is missing', async () => { + const response = await GET({ + params: { + version: 'v6', + page: 'alert', + }, + url: new URL('http://localhost/api/v6/alert/css'), + } as any) + const body = await response.json() + + expect(response.status).toBe(400) + expect(body).toHaveProperty('error') + expect(body.error).toContain('Version, section, and page parameters are required') +}) + +it('returns 400 error when page parameter is missing', async () => { + const response = await GET({ + params: { + version: 'v6', + section: 'components', + }, + url: new URL('http://localhost/api/v6/components/css'), + } as any) + const body = await response.json() + + expect(response.status).toBe(400) + expect(body).toHaveProperty('error') + expect(body.error).toContain('Version, section, and page parameters are required') +}) + +it('returns 400 error when all parameters are missing', async () => { + const response = await GET({ + params: {}, + url: new URL('http://localhost/api/css'), + } as any) + const body = await response.json() + + expect(response.status).toBe(400) + expect(body).toHaveProperty('error') + expect(body.error).toContain('Version, section, and page parameters are required') +}) + +it('returns 500 error when fetchApiIndex fails', async () => { + const { fetchApiIndex } = require('../../../../../../../utils/apiIndex/fetch') + fetchApiIndex.mockRejectedValueOnce(new Error('Network error')) + + const response = await GET({ + params: { + version: 'v6', + section: 'components', + page: 'alert', + }, + url: new URL('http://localhost/api/v6/components/alert/css'), + } as any) + const body = await response.json() + + expect(response.status).toBe(500) + expect(body).toHaveProperty('error') + expect(body).toHaveProperty('details') + expect(body.error).toBe('Failed to load API index') + expect(body.details).toBe('Network error') +}) + +it('returns 500 error when fetchApiIndex throws a non-Error object', async () => { + const { fetchApiIndex } = require('../../../../../../../utils/apiIndex/fetch') + fetchApiIndex.mockRejectedValueOnce('String error') + + const response = await GET({ + params: { + version: 'v6', + section: 'components', + page: 'alert', + }, + url: new URL('http://localhost/api/v6/components/alert/css'), + } as any) + const body = await response.json() + + expect(response.status).toBe(500) + expect(body).toHaveProperty('error') + expect(body).toHaveProperty('details') + expect(body.error).toBe('Failed to load API index') + expect(body.details).toBe('String error') +}) + +it('returns empty array when CSS tokens array exists but is empty', async () => { + const { fetchApiIndex } = require('../../../../../../../utils/apiIndex/fetch') + fetchApiIndex.mockResolvedValueOnce({ + versions: ['v6'], + sections: { + v6: ['components'], + }, + pages: { + 'v6::components': ['empty'], + }, + tabs: { + 'v6::components::empty': ['react'], + }, + css: { + 'v6::components::empty': [], + }, + }) + + const response = await GET({ + params: { + version: 'v6', + section: 'components', + page: 'empty', + }, + url: new URL('http://localhost/api/v6/components/empty/css'), + } as any) + const body = await response.json() + + expect(response.status).toBe(404) + expect(body).toHaveProperty('error') + expect(body.error).toContain('No CSS tokens found') +}) From 69413e0ef48d30244d5f130d639166c6cf1c579d Mon Sep 17 00:00:00 2001 From: Donald Labaj Date: Thu, 22 Jan 2026 16:06:16 -0500 Subject: [PATCH 03/10] Fixed styles and linting errors. --- src/utils/extractReactTokens.ts | 91 +++++++++++++++++---------------- 1 file changed, 48 insertions(+), 43 deletions(-) diff --git a/src/utils/extractReactTokens.ts b/src/utils/extractReactTokens.ts index 305708f..75f352c 100644 --- a/src/utils/extractReactTokens.ts +++ b/src/utils/extractReactTokens.ts @@ -21,7 +21,7 @@ function cssPrefixToTokenPrefix(cssPrefix: string): string { */ export async function extractReactTokens( cssPrefix: string | string[], -): Promise> { +): Promise<{ name: string; value: string; var: string }[]> { // Handle both single prefix and array of prefixes to support the subcomponents. const prefixes = Array.isArray(cssPrefix) ? cssPrefix : [cssPrefix] const tokenPrefixes = prefixes.map(cssPrefixToTokenPrefix) @@ -37,6 +37,7 @@ export async function extractReactTokens( ) if (!existsSync(tokensDir)) { + // eslint-disable-next-line no-console console.error(`Tokens directory not found: ${tokensDir}`) return [] } @@ -63,56 +64,60 @@ export async function extractReactTokens( }) // Import and extract objects from each matching file - const tokenObjects: Array<{ name: string; value: string; var: string }> = [] + const tokenObjects: { name: string; value: string; var: string }[] = [] - for (const file of matchingFiles) { - try { - const filePath = join(tokensDir, file) - const fileContent = await readFile(filePath, 'utf8') + await Promise.all( + matchingFiles.map(async (file) => { + try { + const filePath = join(tokensDir, file) + const fileContent = await readFile(filePath, 'utf8') - // Extract the exported object using regex - // Pattern: export const variableName = { "name": "...", "value": "...", "var": "..." }; - // Use non-greedy match to get just the first exported const object - const objectMatch = fileContent.match( - /export const \w+ = \{[\s\S]*?\n\};/, - ) + // Extract the exported object using regex + // Pattern: export const variableName = { "name": "...", "value": "...", "var": "..." }; + // Use non-greedy match to get just the first exported const object + const objectMatch = fileContent.match( + /export const \w+ = \{[\s\S]*?\n\};/, + ) - if (objectMatch) { - // Parse the object string to extract the JSON-like object - const objectContent = objectMatch[0] - .replace(/export const \w+ = /, '') - .replace(/;$/, '') + if (objectMatch) { + // Parse the object string to extract the JSON-like object + const objectContent = objectMatch[0] + .replace(/export const \w+ = /, '') + .replace(/;$/, '') - try { - // Use Function constructor for safe evaluation - // The object content is valid JavaScript, so we can evaluate it - const tokenObject = new Function(`return ${objectContent}`)() as { - name: string - value: string - var: string - } + try { + // Use Function constructor for safe evaluation + // The object content is valid JavaScript, so we can evaluate it + const tokenObject = new Function(`return ${objectContent}`)() as { + name: string + value: string + var: string + } - if ( - tokenObject && - typeof tokenObject === 'object' && - typeof tokenObject.name === 'string' && - typeof tokenObject.value === 'string' && - typeof tokenObject.var === 'string' - ) { - tokenObjects.push({ - name: tokenObject.name, - value: tokenObject.value, - var: tokenObject.var, - }) + if ( + tokenObject && + typeof tokenObject === 'object' && + typeof tokenObject.name === 'string' && + typeof tokenObject.value === 'string' && + typeof tokenObject.var === 'string' + ) { + tokenObjects.push({ + name: tokenObject.name, + value: tokenObject.value, + var: tokenObject.var, + }) + } + } catch (evalError) { + // eslint-disable-next-line no-console + console.warn(`Failed to parse object from ${file}:`, evalError) } - } catch (evalError) { - console.warn(`Failed to parse object from ${file}:`, evalError) } + } catch (error) { + // eslint-disable-next-line no-console + console.warn(`Failed to read file ${file}:`, error) } - } catch (error) { - console.warn(`Failed to read file ${file}:`, error) - } - } + }), + ) // Sort by name for consistent ordering return tokenObjects.sort((a, b) => a.name.localeCompare(b.name)) From e09465157c74f5c5a938d7cac41d0f2b5ef0873c Mon Sep 17 00:00:00 2001 From: Donald Labaj Date: Thu, 22 Jan 2026 16:14:32 -0500 Subject: [PATCH 04/10] Removed non standard code. --- .../[version]/[section]/[page]/css.test.ts | 3 + .../__tests__/extractReactTokens.test.ts | 55 ++----------- src/utils/apiIndex/generate.ts | 4 +- src/utils/apiIndex/get.ts | 2 +- src/utils/extractReactTokens.ts | 80 ++++++++----------- 5 files changed, 45 insertions(+), 99 deletions(-) diff --git a/src/__tests__/pages/api/__tests__/[version]/[section]/[page]/css.test.ts b/src/__tests__/pages/api/__tests__/[version]/[section]/[page]/css.test.ts index bb835d6..278fbd1 100644 --- a/src/__tests__/pages/api/__tests__/[version]/[section]/[page]/css.test.ts +++ b/src/__tests__/pages/api/__tests__/[version]/[section]/[page]/css.test.ts @@ -161,6 +161,7 @@ it('returns 400 error when all parameters are missing', async () => { }) it('returns 500 error when fetchApiIndex fails', async () => { + // eslint-disable-next-line @typescript-eslint/no-require-imports const { fetchApiIndex } = require('../../../../../../../utils/apiIndex/fetch') fetchApiIndex.mockRejectedValueOnce(new Error('Network error')) @@ -182,6 +183,7 @@ it('returns 500 error when fetchApiIndex fails', async () => { }) it('returns 500 error when fetchApiIndex throws a non-Error object', async () => { + // eslint-disable-next-line @typescript-eslint/no-require-imports const { fetchApiIndex } = require('../../../../../../../utils/apiIndex/fetch') fetchApiIndex.mockRejectedValueOnce('String error') @@ -203,6 +205,7 @@ it('returns 500 error when fetchApiIndex throws a non-Error object', async () => }) it('returns empty array when CSS tokens array exists but is empty', async () => { + // eslint-disable-next-line @typescript-eslint/no-require-imports const { fetchApiIndex } = require('../../../../../../../utils/apiIndex/fetch') fetchApiIndex.mockResolvedValueOnce({ versions: ['v6'], diff --git a/src/utils/__tests__/extractReactTokens.test.ts b/src/utils/__tests__/extractReactTokens.test.ts index eb395a9..abf1517 100644 --- a/src/utils/__tests__/extractReactTokens.test.ts +++ b/src/utils/__tests__/extractReactTokens.test.ts @@ -32,9 +32,6 @@ afterAll(() => { describe('extractReactTokens', () => { beforeEach(() => { jest.clearAllMocks() - // Reset console methods - jest.spyOn(console, 'error').mockImplementation(() => {}) - jest.spyOn(console, 'warn').mockImplementation(() => {}) }) afterEach(() => { @@ -91,9 +88,6 @@ describe('extractReactTokens', () => { const result = await extractReactTokens('pf-v6-c-accordion') expect(result).toEqual([]) - expect(console.error).toHaveBeenCalledWith( - expect.stringContaining('Tokens directory not found'), - ) expect(readdir).not.toHaveBeenCalled() }) @@ -121,7 +115,7 @@ describe('extractReactTokens', () => { 'export const token = { "name": "test", "value": "test", "var": "--test" };', ) - const result = await extractReactTokens('pf-v6-c-accordion') + await extractReactTokens('pf-v6-c-accordion') expect(readFile).toHaveBeenCalledTimes(1) expect(readFile).toHaveBeenCalledWith( @@ -140,7 +134,7 @@ describe('extractReactTokens', () => { 'export const token = { "name": "test", "value": "test", "var": "--test" };', ) - const result = await extractReactTokens('pf-v6-c-accordion') + await extractReactTokens('pf-v6-c-accordion') expect(readFile).toHaveBeenCalledTimes(1) expect(readFile).not.toHaveBeenCalledWith( @@ -160,7 +154,7 @@ describe('extractReactTokens', () => { 'export const token = { "name": "test", "value": "test", "var": "--test" };', ) - const result = await extractReactTokens('pf-v6-c-accordion') + await extractReactTokens('pf-v6-c-accordion') expect(readFile).toHaveBeenCalledTimes(2) expect(readFile).not.toHaveBeenCalledWith( @@ -188,7 +182,7 @@ describe('extractReactTokens', () => { 'export const token = { "name": "test", "value": "test", "var": "--test" };', ) - const result = await extractReactTokens('pf-v6-c-accordion') + await extractReactTokens('pf-v6-c-accordion') expect(readFile).toHaveBeenCalledTimes(3) }) @@ -204,7 +198,7 @@ describe('extractReactTokens', () => { 'export const token = { "name": "test", "value": "test", "var": "--test" };', ) - const result = await extractReactTokens([ + await extractReactTokens([ 'pf-v6-c-accordion', 'pf-v6-c-button', ]) @@ -363,45 +357,6 @@ describe('extractReactTokens', () => { }) describe('error handling', () => { - it('handles file read errors gracefully', async () => { - ;(existsSync as jest.Mock).mockReturnValue(true) - ;(readdir as jest.Mock).mockResolvedValue([ - 'c_accordion__toggle_FontFamily.js', - 'c_accordion__header_BackgroundColor.js', - ]) - ;(readFile as jest.Mock) - .mockRejectedValueOnce(new Error('Permission denied')) - .mockResolvedValueOnce( - 'export const token = { "name": "test", "value": "test", "var": "--test"\n};', - ) - - const result = await extractReactTokens('pf-v6-c-accordion') - - expect(console.warn).toHaveBeenCalledWith( - expect.stringContaining('Failed to read file'), - expect.any(Error), - ) - expect(result).toHaveLength(1) - }) - - it('handles object parsing errors gracefully', async () => { - ;(existsSync as jest.Mock).mockReturnValue(true) - ;(readdir as jest.Mock).mockResolvedValue([ - 'c_accordion__toggle_FontFamily.js', - ]) - ;(readFile as jest.Mock).mockResolvedValue( - 'export const token = { invalid syntax\n};', // Invalid JavaScript - ) - - const result = await extractReactTokens('pf-v6-c-accordion') - - expect(console.warn).toHaveBeenCalledWith( - expect.stringContaining('Failed to parse object'), - expect.any(Error), - ) - expect(result).toEqual([]) - }) - it('handles readdir errors gracefully', async () => { ;(existsSync as jest.Mock).mockReturnValue(true) ;(readdir as jest.Mock).mockRejectedValue(new Error('Directory read failed')) diff --git a/src/utils/apiIndex/generate.ts b/src/utils/apiIndex/generate.ts index bfbde0e..7262870 100644 --- a/src/utils/apiIndex/generate.ts +++ b/src/utils/apiIndex/generate.ts @@ -62,7 +62,7 @@ export interface ApiIndex { */ examples: Record /** CSS token objects by version::section::page (e.g., { 'v6::components::accordion': [{name: '--pf-v6-c-accordion--...', value: '...', var: '...'}] }) */ - css: Record> + css: Record } /** @@ -149,7 +149,7 @@ export async function generateApiIndex(): Promise { const sectionPages: Record> = {} const pageTabs: Record> = {} const tabExamples: Record = {} - const pageCss: Record> = {} + const pageCss: Record = {} const pageCssPrefixes: Record = {} flatEntries.forEach((entry: any) => { diff --git a/src/utils/apiIndex/get.ts b/src/utils/apiIndex/get.ts index e35a662..c25ffdb 100644 --- a/src/utils/apiIndex/get.ts +++ b/src/utils/apiIndex/get.ts @@ -134,7 +134,7 @@ export async function getCssTokens( version: string, section: string, page: string, -): Promise> { +): Promise<{ name: string; value: string; var: string }[]> { const index = await getApiIndex() const { createIndexKey } = await import('../apiHelpers') const key = createIndexKey(version, section, page) diff --git a/src/utils/extractReactTokens.ts b/src/utils/extractReactTokens.ts index 75f352c..0f47ec2 100644 --- a/src/utils/extractReactTokens.ts +++ b/src/utils/extractReactTokens.ts @@ -4,7 +4,7 @@ import { existsSync } from 'fs' /** * Converts a CSS prefix (e.g., "pf-v6-c-accordion") to a token prefix (e.g., "c_accordion") - * + * * @param cssPrefix - The CSS prefix from front matter * @returns The token prefix used in file names */ @@ -15,7 +15,7 @@ function cssPrefixToTokenPrefix(cssPrefix: string): string { /** * Extracts all token objects from @patternfly/react-tokens that match a given CSS prefix - * + * * @param cssPrefix - The CSS prefix (e.g., "pf-v6-c-accordion") * @returns Array of token objects with name, value, and var properties */ @@ -37,8 +37,6 @@ export async function extractReactTokens( ) if (!existsSync(tokensDir)) { - // eslint-disable-next-line no-console - console.error(`Tokens directory not found: ${tokensDir}`) return [] } @@ -68,53 +66,43 @@ export async function extractReactTokens( await Promise.all( matchingFiles.map(async (file) => { - try { - const filePath = join(tokensDir, file) - const fileContent = await readFile(filePath, 'utf8') + const filePath = join(tokensDir, file) + const fileContent = await readFile(filePath, 'utf8') - // Extract the exported object using regex - // Pattern: export const variableName = { "name": "...", "value": "...", "var": "..." }; - // Use non-greedy match to get just the first exported const object - const objectMatch = fileContent.match( - /export const \w+ = \{[\s\S]*?\n\};/, - ) + // Extract the exported object using regex + // Pattern: export const variableName = { "name": "...", "value": "...", "var": "..." }; + // Use non-greedy match to get just the first exported const object + const objectMatch = fileContent.match( + /export const \w+ = \{[\s\S]*?\n\};/, + ) - if (objectMatch) { - // Parse the object string to extract the JSON-like object - const objectContent = objectMatch[0] - .replace(/export const \w+ = /, '') - .replace(/;$/, '') + if (objectMatch) { + // Parse the object string to extract the JSON-like object + const objectContent = objectMatch[0] + .replace(/export const \w+ = /, '') + .replace(/;$/, '') - try { - // Use Function constructor for safe evaluation - // The object content is valid JavaScript, so we can evaluate it - const tokenObject = new Function(`return ${objectContent}`)() as { - name: string - value: string - var: string - } + // Use Function constructor for safe evaluation + // The object content is valid JavaScript, so we can evaluate it + const tokenObject = new Function(`return ${objectContent}`)() as { + name: string + value: string + var: string + } - if ( - tokenObject && - typeof tokenObject === 'object' && - typeof tokenObject.name === 'string' && - typeof tokenObject.value === 'string' && - typeof tokenObject.var === 'string' - ) { - tokenObjects.push({ - name: tokenObject.name, - value: tokenObject.value, - var: tokenObject.var, - }) - } - } catch (evalError) { - // eslint-disable-next-line no-console - console.warn(`Failed to parse object from ${file}:`, evalError) - } + if ( + tokenObject && + typeof tokenObject === 'object' && + typeof tokenObject.name === 'string' && + typeof tokenObject.value === 'string' && + typeof tokenObject.var === 'string' + ) { + tokenObjects.push({ + name: tokenObject.name, + value: tokenObject.value, + var: tokenObject.var, + }) } - } catch (error) { - // eslint-disable-next-line no-console - console.warn(`Failed to read file ${file}:`, error) } }), ) From 48b7f538ac6f59e3b0a2d35950fa9d0bafc8be74 Mon Sep 17 00:00:00 2001 From: Donald Labaj Date: Thu, 22 Jan 2026 16:40:31 -0500 Subject: [PATCH 05/10] Update src/utils/extractReactTokens.ts Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- src/utils/extractReactTokens.ts | 98 ++++++++++++++++++--------------- 1 file changed, 53 insertions(+), 45 deletions(-) diff --git a/src/utils/extractReactTokens.ts b/src/utils/extractReactTokens.ts index 0f47ec2..558bb9d 100644 --- a/src/utils/extractReactTokens.ts +++ b/src/utils/extractReactTokens.ts @@ -52,61 +52,69 @@ export async function extractReactTokens( // Check if file starts with any of the token prefixes // We want individual token files (e.g., c_accordion__toggle_FontFamily.js) // but not the main component index file (e.g., c_accordion.js) - return tokenPrefixes.some((prefix) => { - if (file === `${prefix}.js`) { - // This is the main component file, skip it - return false - } - return file.startsWith(prefix) - }) - }) - // Import and extract objects from each matching file - const tokenObjects: { name: string; value: string; var: string }[] = [] + const results = await Promise.all( + matchingFiles.map(async (file): Promise<{ name: string; value: string; var: string } | null> => { + try { + const filePath = join(tokensDir, file) + const fileContent = await readFile(filePath, 'utf8') - await Promise.all( - matchingFiles.map(async (file) => { - const filePath = join(tokensDir, file) - const fileContent = await readFile(filePath, 'utf8') + // Extract the exported object using regex + // Pattern: export const variableName = { "name": "...", "value": "...", "var": "..." }; + // Use non-greedy match to get just the first exported const object + const objectMatch = fileContent.match( + /export const \w+ = \{[\s\S]*?\n\};/, + ) - // Extract the exported object using regex - // Pattern: export const variableName = { "name": "...", "value": "...", "var": "..." }; - // Use non-greedy match to get just the first exported const object - const objectMatch = fileContent.match( - /export const \w+ = \{[\s\S]*?\n\};/, - ) + if (objectMatch) { + // Parse the object string to extract the JSON-like object + const objectContent = objectMatch[0] + .replace(/export const \w+ = /, '') + .replace(/;$/, '') - if (objectMatch) { - // Parse the object string to extract the JSON-like object - const objectContent = objectMatch[0] - .replace(/export const \w+ = /, '') - .replace(/;$/, '') + try { + // Use Function constructor for safe evaluation + // The object content is valid JavaScript, so we can evaluate it + const tokenObject = new Function(`return ${objectContent}`)() as { + name: string + value: string + var: string + } - // Use Function constructor for safe evaluation - // The object content is valid JavaScript, so we can evaluate it - const tokenObject = new Function(`return ${objectContent}`)() as { - name: string - value: string - var: string - } - - if ( - tokenObject && - typeof tokenObject === 'object' && - typeof tokenObject.name === 'string' && - typeof tokenObject.value === 'string' && - typeof tokenObject.var === 'string' - ) { - tokenObjects.push({ - name: tokenObject.name, - value: tokenObject.value, - var: tokenObject.var, - }) + if ( + tokenObject && + typeof tokenObject === 'object' && + typeof tokenObject.name === 'string' && + typeof tokenObject.value === 'string' && + typeof tokenObject.var === 'string' + ) { + return { + name: tokenObject.name, + value: tokenObject.value, + var: tokenObject.var, + } + } + } catch (evalError) { + // eslint-disable-next-line no-console + console.warn(`Failed to parse object from ${file}:`, evalError) + } } + } catch (error) { + // eslint-disable-next-line no-console + console.warn(`Failed to read file ${file}:`, error) } + return null }), ) + // Filter out null results + const tokenObjects = results.filter( + (obj): obj is { name: string; value: string; var: string } => obj !== null, + ) + + // Sort by name for consistent ordering + return tokenObjects.sort((a, b) => a.name.localeCompare(b.name)) + // Sort by name for consistent ordering return tokenObjects.sort((a, b) => a.name.localeCompare(b.name)) } From c4e8c978bc83ee96de32f17f37452dd263c4e88c Mon Sep 17 00:00:00 2001 From: Donald Labaj Date: Thu, 22 Jan 2026 16:54:57 -0500 Subject: [PATCH 06/10] undid code rabbit modification. --- src/utils/extractReactTokens.ts | 98 +++++++++++++++------------------ 1 file changed, 45 insertions(+), 53 deletions(-) diff --git a/src/utils/extractReactTokens.ts b/src/utils/extractReactTokens.ts index 558bb9d..0f47ec2 100644 --- a/src/utils/extractReactTokens.ts +++ b/src/utils/extractReactTokens.ts @@ -52,69 +52,61 @@ export async function extractReactTokens( // Check if file starts with any of the token prefixes // We want individual token files (e.g., c_accordion__toggle_FontFamily.js) // but not the main component index file (e.g., c_accordion.js) + return tokenPrefixes.some((prefix) => { + if (file === `${prefix}.js`) { + // This is the main component file, skip it + return false + } + return file.startsWith(prefix) + }) + }) + // Import and extract objects from each matching file - const results = await Promise.all( - matchingFiles.map(async (file): Promise<{ name: string; value: string; var: string } | null> => { - try { - const filePath = join(tokensDir, file) - const fileContent = await readFile(filePath, 'utf8') + const tokenObjects: { name: string; value: string; var: string }[] = [] - // Extract the exported object using regex - // Pattern: export const variableName = { "name": "...", "value": "...", "var": "..." }; - // Use non-greedy match to get just the first exported const object - const objectMatch = fileContent.match( - /export const \w+ = \{[\s\S]*?\n\};/, - ) + await Promise.all( + matchingFiles.map(async (file) => { + const filePath = join(tokensDir, file) + const fileContent = await readFile(filePath, 'utf8') - if (objectMatch) { - // Parse the object string to extract the JSON-like object - const objectContent = objectMatch[0] - .replace(/export const \w+ = /, '') - .replace(/;$/, '') + // Extract the exported object using regex + // Pattern: export const variableName = { "name": "...", "value": "...", "var": "..." }; + // Use non-greedy match to get just the first exported const object + const objectMatch = fileContent.match( + /export const \w+ = \{[\s\S]*?\n\};/, + ) - try { - // Use Function constructor for safe evaluation - // The object content is valid JavaScript, so we can evaluate it - const tokenObject = new Function(`return ${objectContent}`)() as { - name: string - value: string - var: string - } + if (objectMatch) { + // Parse the object string to extract the JSON-like object + const objectContent = objectMatch[0] + .replace(/export const \w+ = /, '') + .replace(/;$/, '') - if ( - tokenObject && - typeof tokenObject === 'object' && - typeof tokenObject.name === 'string' && - typeof tokenObject.value === 'string' && - typeof tokenObject.var === 'string' - ) { - return { - name: tokenObject.name, - value: tokenObject.value, - var: tokenObject.var, - } - } - } catch (evalError) { - // eslint-disable-next-line no-console - console.warn(`Failed to parse object from ${file}:`, evalError) - } + // Use Function constructor for safe evaluation + // The object content is valid JavaScript, so we can evaluate it + const tokenObject = new Function(`return ${objectContent}`)() as { + name: string + value: string + var: string + } + + if ( + tokenObject && + typeof tokenObject === 'object' && + typeof tokenObject.name === 'string' && + typeof tokenObject.value === 'string' && + typeof tokenObject.var === 'string' + ) { + tokenObjects.push({ + name: tokenObject.name, + value: tokenObject.value, + var: tokenObject.var, + }) } - } catch (error) { - // eslint-disable-next-line no-console - console.warn(`Failed to read file ${file}:`, error) } - return null }), ) - // Filter out null results - const tokenObjects = results.filter( - (obj): obj is { name: string; value: string; var: string } => obj !== null, - ) - - // Sort by name for consistent ordering - return tokenObjects.sort((a, b) => a.name.localeCompare(b.name)) - // Sort by name for consistent ordering return tokenObjects.sort((a, b) => a.name.localeCompare(b.name)) } From 4f6281c82e773cf051004e536b20923732a26f02 Mon Sep 17 00:00:00 2001 From: Donald Labaj Date: Wed, 4 Feb 2026 15:42:29 -0500 Subject: [PATCH 07/10] refactor(extractReactTokens): replace Function constructor with JSON parsing for safer token extraction --- src/utils/extractReactTokens.ts | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/src/utils/extractReactTokens.ts b/src/utils/extractReactTokens.ts index 0f47ec2..ed40cb1 100644 --- a/src/utils/extractReactTokens.ts +++ b/src/utils/extractReactTokens.ts @@ -82,24 +82,30 @@ export async function extractReactTokens( .replace(/export const \w+ = /, '') .replace(/;$/, '') - // Use Function constructor for safe evaluation - // The object content is valid JavaScript, so we can evaluate it - const tokenObject = new Function(`return ${objectContent}`)() as { - name: string - value: string - var: string + // Parse as JSON - the token format uses double-quoted keys/values, so it's JSON-compatible. + // Avoids new Function()/eval which would execute arbitrary code from dependency files. + let tokenObject: { name: string; value: string | string[]; var: string } + try { + tokenObject = JSON.parse(objectContent) + } catch { + return // Skip malformed content } if ( tokenObject && typeof tokenObject === 'object' && typeof tokenObject.name === 'string' && - typeof tokenObject.value === 'string' && + (typeof tokenObject.value === 'string' || + (Array.isArray(tokenObject.value) && + tokenObject.value.every((v) => typeof v === 'string'))) && typeof tokenObject.var === 'string' ) { + const value = Array.isArray(tokenObject.value) + ? tokenObject.value.join(', ') + : tokenObject.value tokenObjects.push({ name: tokenObject.name, - value: tokenObject.value, + value, var: tokenObject.var, }) } From 584ed7d0600a72c43dde05732639181ee7ba2081 Mon Sep 17 00:00:00 2001 From: Donald Labaj Date: Wed, 4 Feb 2026 15:54:31 -0500 Subject: [PATCH 08/10] Updated with review comments. --- .../[version]/[section]/[page]/css.test.ts | 23 +- .../api/[version]/[section]/[page]/css.ts | 7 +- src/pages/api/index.ts | 2 +- .../__tests__/extractReactTokens.test.ts | 488 +++++------------- src/utils/apiIndex/get.ts | 2 +- src/utils/extractReactTokens.ts | 142 +++-- 6 files changed, 188 insertions(+), 476 deletions(-) diff --git a/src/__tests__/pages/api/__tests__/[version]/[section]/[page]/css.test.ts b/src/__tests__/pages/api/__tests__/[version]/[section]/[page]/css.test.ts index 278fbd1..5638503 100644 --- a/src/__tests__/pages/api/__tests__/[version]/[section]/[page]/css.test.ts +++ b/src/__tests__/pages/api/__tests__/[version]/[section]/[page]/css.test.ts @@ -21,11 +21,13 @@ jest.mock('../../../../../../../utils/apiIndex/fetch', () => ({ { name: '--pf-v6-c-alert--BackgroundColor', value: '#ffffff', + var: '--pf-v6-c-alert--BackgroundColor', description: 'Alert background color', }, { name: '--pf-v6-c-alert--Color', value: '#151515', + var: '--pf-v6-c-alert--Color', description: 'Alert text color', }, ], @@ -33,6 +35,7 @@ jest.mock('../../../../../../../utils/apiIndex/fetch', () => ({ { name: '--pf-v6-c-button--BackgroundColor', value: '#0066cc', + var: '--pf-v6-c-button--BackgroundColor', description: 'Button background color', }, ], @@ -61,7 +64,7 @@ it('returns CSS tokens for a valid page', async () => { expect(body).toHaveLength(2) expect(body[0]).toHaveProperty('name') expect(body[0]).toHaveProperty('value') - expect(body[0]).toHaveProperty('description') + expect(body[0]).toHaveProperty('var') expect(body[0].name).toBe('--pf-v6-c-alert--BackgroundColor') expect(body[0].value).toBe('#ffffff') }) @@ -83,7 +86,7 @@ it('returns CSS tokens for different pages', async () => { expect(buttonBody[0].name).toBe('--pf-v6-c-button--BackgroundColor') }) -it('returns 404 error when no CSS tokens are found', async () => { +it('returns empty array when no CSS tokens are found for page', async () => { const response = await GET({ params: { version: 'v6', @@ -94,13 +97,9 @@ it('returns 404 error when no CSS tokens are found', async () => { } as any) const body = await response.json() - expect(response.status).toBe(404) - expect(body).toHaveProperty('error') - expect(body.error).toContain('No CSS tokens found') - expect(body.error).toContain('nonexistent') - expect(body.error).toContain('components') - expect(body.error).toContain('v6') - expect(body.error).toContain('cssPrefix') + expect(response.status).toBe(200) + expect(Array.isArray(body)).toBe(true) + expect(body).toHaveLength(0) }) it('returns 400 error when version parameter is missing', async () => { @@ -233,7 +232,7 @@ it('returns empty array when CSS tokens array exists but is empty', async () => } as any) const body = await response.json() - expect(response.status).toBe(404) - expect(body).toHaveProperty('error') - expect(body.error).toContain('No CSS tokens found') + expect(response.status).toBe(200) + expect(Array.isArray(body)).toBe(true) + expect(body).toHaveLength(0) }) diff --git a/src/pages/api/[version]/[section]/[page]/css.ts b/src/pages/api/[version]/[section]/[page]/css.ts index e6c6faf..bdd1dba 100644 --- a/src/pages/api/[version]/[section]/[page]/css.ts +++ b/src/pages/api/[version]/[section]/[page]/css.ts @@ -20,12 +20,7 @@ export const GET: APIRoute = async ({ params, url }) => { const cssTokens = index.css[pageKey] || [] if (cssTokens.length === 0) { - return createJsonResponse( - { - error: `No CSS tokens found for page '${page}' in section '${section}' for version '${version}'. CSS tokens are only available for content with a cssPrefix in the front matter.`, - }, - 404, - ) + return createJsonResponse([]) } return createJsonResponse(cssTokens) diff --git a/src/pages/api/index.ts b/src/pages/api/index.ts index 757a51d..ae1abeb 100644 --- a/src/pages/api/index.ts +++ b/src/pages/api/index.ts @@ -237,7 +237,7 @@ export const GET: APIRoute = async () => items: 'object', description: 'Array of CSS token objects with tokenName, value, and variableName', example: [ - { tokenName: 'c_alert__Background', value: '#000000', variableName: 'c_alert__Background' }, + { name: 'c_alert__Background', value: '#000000', var: 'c_alert__Background' }, ], }, }, diff --git a/src/utils/__tests__/extractReactTokens.test.ts b/src/utils/__tests__/extractReactTokens.test.ts index abf1517..e07e03b 100644 --- a/src/utils/__tests__/extractReactTokens.test.ts +++ b/src/utils/__tests__/extractReactTokens.test.ts @@ -1,453 +1,197 @@ -import { readdir, readFile } from 'fs/promises' -import { existsSync } from 'fs' -import { join } from 'path' import { extractReactTokens } from '../extractReactTokens' -// Mock fs/promises -jest.mock('fs/promises', () => ({ - readdir: jest.fn(), - readFile: jest.fn(), -})) - -// Mock fs -jest.mock('fs', () => ({ - existsSync: jest.fn(), -})) - -// Mock path -jest.mock('path', () => ({ - join: jest.fn((...args) => args.join('/')), -})) - -// Mock process.cwd -const originalCwd = process.cwd -beforeAll(() => { - process.cwd = jest.fn(() => '/test/project') -}) - -afterAll(() => { - process.cwd = originalCwd -}) +const mockComponentIndex: Record< + string, + Record> +> = { + c_accordion: { + '.pf-v6-c-accordion': { + c_accordion__toggle_FontFamily: { + name: '--pf-v6-c-accordion--toggle--FontFamily', + value: '1rem', + values: ['--pf-t--global--font--size--200', '1rem'], + }, + c_accordion__header_BackgroundColor: { + name: '--pf-v6-c-accordion--header--BackgroundColor', + value: '#fff', + }, + c_accordion__expandable_content_m_fixed_MaxHeight: { + name: '--pf-v6-c-accordion__expandable-content--m-fixed--MaxHeight', + value: '9.375rem', + }, + }, + }, + c_button: { + '.pf-v6-c-button': { + c_button__primary_BackgroundColor: { + name: '--pf-v6-c-button--primary--BackgroundColor', + value: '#0066cc', + }, + }, + }, + c_data_list: { + '.pf-v6-c-data-list': { + c_data_list__item_row_BackgroundColor: { + name: '--pf-v6-c-data-list__item--row--BackgroundColor', + value: '#ffffff', + }, + }, + }, + c_empty: {}, +} + +jest.mock('@patternfly/react-tokens/dist/esm/componentIndex', () => mockComponentIndex) describe('extractReactTokens', () => { beforeEach(() => { - jest.clearAllMocks() - }) - - afterEach(() => { - jest.restoreAllMocks() + jest.resetModules() }) describe('CSS prefix to token prefix conversion', () => { it('converts single CSS prefix correctly', async () => { - ;(existsSync as jest.Mock).mockReturnValue(true) - ;(readdir as jest.Mock).mockResolvedValue([]) - - await extractReactTokens('pf-v6-c-accordion') + const result = await extractReactTokens('pf-v6-c-accordion') - expect(join).toHaveBeenCalledWith( - '/test/project', - 'node_modules', - '@patternfly', - 'react-tokens', - 'dist', - 'esm', + expect(result.length).toBeGreaterThan(0) + expect(result.every((t) => t.name.startsWith('--pf-v6-c-accordion'))).toBe( + true, ) }) it('converts array of CSS prefixes correctly', async () => { - ;(existsSync as jest.Mock).mockReturnValue(true) - ;(readdir as jest.Mock).mockResolvedValue([]) - - await extractReactTokens(['pf-v6-c-accordion', 'pf-v6-c-button']) + const result = await extractReactTokens([ + 'pf-v6-c-accordion', + 'pf-v6-c-button', + ]) - expect(join).toHaveBeenCalledWith( - '/test/project', - 'node_modules', - '@patternfly', - 'react-tokens', - 'dist', - 'esm', + const accordionTokens = result.filter((t) => + t.name.includes('accordion'), ) + const buttonTokens = result.filter((t) => t.name.includes('button')) + expect(accordionTokens.length).toBeGreaterThan(0) + expect(buttonTokens.length).toBeGreaterThan(0) }) it('handles CSS prefix without pf-v6- prefix', async () => { - ;(existsSync as jest.Mock).mockReturnValue(true) - ;(readdir as jest.Mock).mockResolvedValue([]) - - await extractReactTokens('c-accordion') + const result = await extractReactTokens('c-accordion') - expect(join).toHaveBeenCalled() + expect(result.length).toBeGreaterThan(0) }) }) - describe('directory existence check', () => { - it('returns empty array when tokens directory does not exist', async () => { - ;(existsSync as jest.Mock).mockReturnValue(false) - - const result = await extractReactTokens('pf-v6-c-accordion') + describe('component resolution', () => { + it('returns empty array when component does not exist in index', async () => { + const result = await extractReactTokens('pf-v6-c-nonexistent') expect(result).toEqual([]) - expect(readdir).not.toHaveBeenCalled() }) - it('returns empty array when tokens directory exists', async () => { - ;(existsSync as jest.Mock).mockReturnValue(true) - ;(readdir as jest.Mock).mockResolvedValue([]) - + it('extracts tokens from componentIndex', async () => { const result = await extractReactTokens('pf-v6-c-accordion') - expect(result).toEqual([]) - expect(readdir).toHaveBeenCalled() + expect(result).toContainEqual({ + name: '--pf-v6-c-accordion--toggle--FontFamily', + value: '1rem', + var: 'var(--pf-v6-c-accordion--toggle--FontFamily)', + }) + expect(result).toContainEqual({ + name: '--pf-v6-c-accordion--header--BackgroundColor', + value: '#fff', + var: 'var(--pf-v6-c-accordion--header--BackgroundColor)', + }) }) }) - describe('file filtering', () => { - it('filters out non-JS files', async () => { - ;(existsSync as jest.Mock).mockReturnValue(true) - ;(readdir as jest.Mock).mockResolvedValue([ - 'c_accordion__toggle_FontFamily.js', - 'c_accordion.ts', - 'c_accordion.json', - 'c_accordion.css', - ]) - ;(readFile as jest.Mock).mockResolvedValue( - 'export const token = { "name": "test", "value": "test", "var": "--test" };', - ) - - await extractReactTokens('pf-v6-c-accordion') - - expect(readFile).toHaveBeenCalledTimes(1) - expect(readFile).toHaveBeenCalledWith( - expect.stringContaining('c_accordion__toggle_FontFamily.js'), - 'utf8', - ) - }) - - it('filters out componentIndex.js', async () => { - ;(existsSync as jest.Mock).mockReturnValue(true) - ;(readdir as jest.Mock).mockResolvedValue([ - 'componentIndex.js', - 'c_accordion__toggle_FontFamily.js', - ]) - ;(readFile as jest.Mock).mockResolvedValue( - 'export const token = { "name": "test", "value": "test", "var": "--test" };', - ) - - await extractReactTokens('pf-v6-c-accordion') - - expect(readFile).toHaveBeenCalledTimes(1) - expect(readFile).not.toHaveBeenCalledWith( - expect.stringContaining('componentIndex.js'), - expect.anything(), - ) - }) - - it('filters out main component file (e.g., c_accordion.js)', async () => { - ;(existsSync as jest.Mock).mockReturnValue(true) - ;(readdir as jest.Mock).mockResolvedValue([ - 'c_accordion.js', - 'c_accordion__toggle_FontFamily.js', - 'c_accordion__header_BackgroundColor.js', - ]) - ;(readFile as jest.Mock).mockResolvedValue( - 'export const token = { "name": "test", "value": "test", "var": "--test" };', + describe('token filtering', () => { + it('filters tokens by prefix for subcomponents', async () => { + const result = await extractReactTokens( + 'pf-v6-c-accordion__expandable-content', ) - await extractReactTokens('pf-v6-c-accordion') - - expect(readFile).toHaveBeenCalledTimes(2) - expect(readFile).not.toHaveBeenCalledWith( - expect.stringContaining('c_accordion.js'), - expect.anything(), - ) - expect(readFile).toHaveBeenCalledWith( - expect.stringContaining('c_accordion__toggle_FontFamily.js'), - 'utf8', - ) - expect(readFile).toHaveBeenCalledWith( - expect.stringContaining('c_accordion__header_BackgroundColor.js'), - 'utf8', - ) - }) - - it('includes files that start with token prefix but are not the main component file', async () => { - ;(existsSync as jest.Mock).mockReturnValue(true) - ;(readdir as jest.Mock).mockResolvedValue([ - 'c_accordion__toggle_FontFamily.js', - 'c_accordion__header_BackgroundColor.js', - 'c_accordion__section_PaddingTop.js', - ]) - ;(readFile as jest.Mock).mockResolvedValue( - 'export const token = { "name": "test", "value": "test", "var": "--test" };', - ) - - await extractReactTokens('pf-v6-c-accordion') - - expect(readFile).toHaveBeenCalledTimes(3) + expect(result).toHaveLength(1) + expect(result[0]).toEqual({ + name: '--pf-v6-c-accordion__expandable-content--m-fixed--MaxHeight', + value: '9.375rem', + var: 'var(--pf-v6-c-accordion__expandable-content--m-fixed--MaxHeight)', + }) }) - it('handles multiple prefixes and matches files for any prefix', async () => { - ;(existsSync as jest.Mock).mockReturnValue(true) - ;(readdir as jest.Mock).mockResolvedValue([ - 'c_accordion__toggle_FontFamily.js', - 'c_button__primary_BackgroundColor.js', - 'c_other_component.js', - ]) - ;(readFile as jest.Mock).mockResolvedValue( - 'export const token = { "name": "test", "value": "test", "var": "--test" };', - ) - - await extractReactTokens([ + it('handles multiple prefixes and returns union of tokens', async () => { + const result = await extractReactTokens([ 'pf-v6-c-accordion', 'pf-v6-c-button', ]) - expect(readFile).toHaveBeenCalledTimes(2) - expect(readFile).toHaveBeenCalledWith( - expect.stringContaining('c_accordion__toggle_FontFamily.js'), - 'utf8', - ) - expect(readFile).toHaveBeenCalledWith( - expect.stringContaining('c_button__primary_BackgroundColor.js'), - 'utf8', + const accordionTokens = result.filter((t) => + t.name.includes('accordion'), ) + const buttonTokens = result.filter((t) => t.name.includes('button')) + expect(accordionTokens.length).toBeGreaterThan(0) + expect(buttonTokens.length).toBeGreaterThan(0) }) - }) - - describe('token extraction from files', () => { - it('extracts token object from valid file content', async () => { - ;(existsSync as jest.Mock).mockReturnValue(true) - ;(readdir as jest.Mock).mockResolvedValue([ - 'c_accordion__toggle_FontFamily.js', - ]) - ;(readFile as jest.Mock).mockResolvedValue( - 'export const c_accordion_toggle_FontFamily = { "name": "c-accordion-toggle-FontFamily", "value": "1rem", "var": "--pf-v6-c-accordion--toggle--FontFamily"\n};', - ) - - const result = await extractReactTokens('pf-v6-c-accordion') - - expect(result).toEqual([ - { - name: 'c-accordion-toggle-FontFamily', - value: '1rem', - var: '--pf-v6-c-accordion--toggle--FontFamily', - }, - ]) - }) - - it('extracts multiple token objects from multiple files', async () => { - ;(existsSync as jest.Mock).mockReturnValue(true) - ;(readdir as jest.Mock).mockResolvedValue([ - 'c_accordion__toggle_FontFamily.js', - 'c_accordion__header_BackgroundColor.js', - ]) - ;(readFile as jest.Mock) - .mockResolvedValueOnce( - 'export const token1 = { "name": "c-accordion-toggle-FontFamily", "value": "1rem", "var": "--pf-v6-c-accordion--toggle--FontFamily"\n};', - ) - .mockResolvedValueOnce( - 'export const token2 = { "name": "c-accordion-header-BackgroundColor", "value": "#fff", "var": "--pf-v6-c-accordion--header--BackgroundColor"\n};', - ) + it('handles files with no matching prefix', async () => { const result = await extractReactTokens('pf-v6-c-accordion') - expect(result).toHaveLength(2) - expect(result).toEqual([ - { - name: 'c-accordion-header-BackgroundColor', - value: '#fff', - var: '--pf-v6-c-accordion--header--BackgroundColor', - }, - { - name: 'c-accordion-toggle-FontFamily', - value: '1rem', - var: '--pf-v6-c-accordion--toggle--FontFamily', - }, - ]) + const otherTokens = result.filter( + (t) => + !t.name.includes('accordion') && !t.name.includes('accordion'), + ) + expect(otherTokens).toHaveLength(0) }) + }) - it('handles file content with multiline object definition', async () => { - ;(existsSync as jest.Mock).mockReturnValue(true) - ;(readdir as jest.Mock).mockResolvedValue([ - 'c_accordion__toggle_FontFamily.js', - ]) - ;(readFile as jest.Mock).mockResolvedValue(`export const token = { - "name": "c-accordion-toggle-FontFamily", - "value": "1rem", - "var": "--pf-v6-c-accordion--toggle--FontFamily" -};`) - + describe('token extraction', () => { + it('extracts token object with correct format', async () => { const result = await extractReactTokens('pf-v6-c-accordion') - expect(result).toEqual([ - { - name: 'c-accordion-toggle-FontFamily', - value: '1rem', - var: '--pf-v6-c-accordion--toggle--FontFamily', - }, - ]) - }) - - it('handles file content with whitespace and comments', async () => { - ;(existsSync as jest.Mock).mockReturnValue(true) - ;(readdir as jest.Mock).mockResolvedValue([ - 'c_accordion__toggle_FontFamily.js', - ]) - ;(readFile as jest.Mock).mockResolvedValue( - '// Some comment\nexport const token = { "name": "test", "value": "test", "var": "--test"\n};// Another comment', + expect(result).toEqual( + expect.arrayContaining([ + { + name: '--pf-v6-c-accordion--toggle--FontFamily', + value: '1rem', + var: 'var(--pf-v6-c-accordion--toggle--FontFamily)', + }, + ]), ) - - const result = await extractReactTokens('pf-v6-c-accordion') - - expect(result).toEqual([ - { - name: 'test', - value: 'test', - var: '--test', - }, - ]) }) - it('skips files that do not match the export pattern', async () => { - ;(existsSync as jest.Mock).mockReturnValue(true) - ;(readdir as jest.Mock).mockResolvedValue([ - 'c_accordion__toggle_FontFamily.js', - 'c_accordion__header_BackgroundColor.js', - ]) - ;(readFile as jest.Mock) - .mockResolvedValueOnce('const token = { "name": "test" };') // No export - .mockResolvedValueOnce( - 'export const token = { "name": "test", "value": "test", "var": "--test"\n};', - ) - + it('deduplicates tokens that appear in multiple selectors', async () => { const result = await extractReactTokens('pf-v6-c-accordion') - expect(result).toHaveLength(1) - expect(result[0].name).toBe('test') - }) - - it('validates token object has required properties', async () => { - ;(existsSync as jest.Mock).mockReturnValue(true) - ;(readdir as jest.Mock).mockResolvedValue([ - 'c_accordion__toggle_FontFamily.js', - ]) - ;(readFile as jest.Mock).mockResolvedValue( - 'export const token = { "name": "test" };', // Missing value and var + const toggleFontFamily = result.filter( + (t) => t.name === '--pf-v6-c-accordion--toggle--FontFamily', ) - - const result = await extractReactTokens('pf-v6-c-accordion') - - expect(result).toEqual([]) + expect(toggleFontFamily).toHaveLength(1) }) - it('validates token object properties are strings', async () => { - ;(existsSync as jest.Mock).mockReturnValue(true) - ;(readdir as jest.Mock).mockResolvedValue([ - 'c_accordion__toggle_FontFamily.js', - ]) - ;(readFile as jest.Mock).mockResolvedValue( - 'export const token = { "name": 123, "value": "test", "var": "--test" };', // name is not a string - ) - + it('validates token object has required properties', async () => { const result = await extractReactTokens('pf-v6-c-accordion') - expect(result).toEqual([]) - }) - }) - - describe('error handling', () => { - it('handles readdir errors gracefully', async () => { - ;(existsSync as jest.Mock).mockReturnValue(true) - ;(readdir as jest.Mock).mockRejectedValue(new Error('Directory read failed')) - - await expect(extractReactTokens('pf-v6-c-accordion')).rejects.toThrow( - 'Directory read failed', - ) + expect(result.every((t) => t.name && t.value && t.var)).toBe(true) }) }) describe('sorting', () => { it('sorts tokens by name alphabetically', async () => { - ;(existsSync as jest.Mock).mockReturnValue(true) - ;(readdir as jest.Mock).mockResolvedValue([ - 'c_accordion__z_token.js', - 'c_accordion__a_token.js', - 'c_accordion__m_token.js', - ]) - ;(readFile as jest.Mock) - .mockResolvedValueOnce( - 'export const token1 = { "name": "z-token", "value": "test", "var": "--test"\n};', - ) - .mockResolvedValueOnce( - 'export const token2 = { "name": "a-token", "value": "test", "var": "--test"\n};', - ) - .mockResolvedValueOnce( - 'export const token3 = { "name": "m-token", "value": "test", "var": "--test"\n};', - ) - const result = await extractReactTokens('pf-v6-c-accordion') - expect(result).toEqual([ - { name: 'a-token', value: 'test', var: '--test' }, - { name: 'm-token', value: 'test', var: '--test' }, - { name: 'z-token', value: 'test', var: '--test' }, - ]) + const sorted = [...result].sort((a, b) => a.name.localeCompare(b.name)) + expect(result).toEqual(sorted) }) }) describe('edge cases', () => { - it('handles empty file list', async () => { - ;(existsSync as jest.Mock).mockReturnValue(true) - ;(readdir as jest.Mock).mockResolvedValue([]) - - const result = await extractReactTokens('pf-v6-c-accordion') + it('handles empty component', async () => { + const result = await extractReactTokens('pf-v6-c-empty') expect(result).toEqual([]) }) - it('handles files with no matching prefix', async () => { - ;(existsSync as jest.Mock).mockReturnValue(true) - ;(readdir as jest.Mock).mockResolvedValue([ - 'other_component__token.js', - 'unrelated_file.js', - ]) - - const result = await extractReactTokens('pf-v6-c-accordion') - - expect(result).toEqual([]) - expect(readFile).not.toHaveBeenCalled() - }) - it('handles CSS prefix with multiple hyphens', async () => { - ;(existsSync as jest.Mock).mockReturnValue(true) - ;(readdir as jest.Mock).mockResolvedValue([ - 'c_data_list__item_row_BackgroundColor.js', - ]) - ;(readFile as jest.Mock).mockResolvedValue( - 'export const token = { "name": "test", "value": "test", "var": "--test"\n};', - ) - const result = await extractReactTokens('pf-v6-c-data-list') - expect(readFile).toHaveBeenCalled() - expect(result).toHaveLength(1) - }) - - it('handles file content with multiple export statements', async () => { - ;(existsSync as jest.Mock).mockReturnValue(true) - ;(readdir as jest.Mock).mockResolvedValue([ - 'c_accordion__toggle_FontFamily.js', - ]) - ;(readFile as jest.Mock).mockResolvedValue( - 'export const token1 = { "name": "first", "value": "test", "var": "--test"\n};export const token2 = { "name": "second", "value": "test", "var": "--test"\n};', - ) - - const result = await extractReactTokens('pf-v6-c-accordion') - - // Should only extract the first matching export expect(result).toHaveLength(1) - expect(result[0].name).toBe('first') + expect(result[0].name).toContain('data-list') }) }) }) diff --git a/src/utils/apiIndex/get.ts b/src/utils/apiIndex/get.ts index c25ffdb..8428644 100644 --- a/src/utils/apiIndex/get.ts +++ b/src/utils/apiIndex/get.ts @@ -2,6 +2,7 @@ import { join } from 'path' import { readFile } from 'fs/promises' import type { ApiIndex } from './generate' import { getOutputDir } from '../getOutputDir' +import { createIndexKey } from'../apiHelpers'; /** * Reads and parses the API index file @@ -136,7 +137,6 @@ export async function getCssTokens( page: string, ): Promise<{ name: string; value: string; var: string }[]> { const index = await getApiIndex() - const { createIndexKey } = await import('../apiHelpers') const key = createIndexKey(version, section, page) return index.css[key] || [] } diff --git a/src/utils/extractReactTokens.ts b/src/utils/extractReactTokens.ts index ed40cb1..1b17c80 100644 --- a/src/utils/extractReactTokens.ts +++ b/src/utils/extractReactTokens.ts @@ -1,20 +1,22 @@ -import { readdir, readFile } from 'fs/promises' -import { join } from 'path' -import { existsSync } from 'fs' - /** * Converts a CSS prefix (e.g., "pf-v6-c-accordion") to a token prefix (e.g., "c_accordion") * * @param cssPrefix - The CSS prefix from front matter - * @returns The token prefix used in file names + * @returns The token prefix used in componentIndex */ function cssPrefixToTokenPrefix(cssPrefix: string): string { // Remove "pf-v6-" prefix and replace hyphens with underscores to match the tokens. return cssPrefix.replace(/^pf-v6-/, '').replace(/-+/g, '_') } +type TokenEntry = { + name: string + value: string + values?: string[] +} + /** - * Extracts all token objects from @patternfly/react-tokens that match a given CSS prefix + * Extracts all token objects from @patternfly/react-tokens componentIndex that match a given CSS prefix * * @param cssPrefix - The CSS prefix (e.g., "pf-v6-c-accordion") * @returns Array of token objects with name, value, and var properties @@ -22,97 +24,69 @@ function cssPrefixToTokenPrefix(cssPrefix: string): string { export async function extractReactTokens( cssPrefix: string | string[], ): Promise<{ name: string; value: string; var: string }[]> { - // Handle both single prefix and array of prefixes to support the subcomponents. + // Handle both single prefix and array of prefixes to support subcomponents. const prefixes = Array.isArray(cssPrefix) ? cssPrefix : [cssPrefix] const tokenPrefixes = prefixes.map(cssPrefixToTokenPrefix) - // Path to the react-tokens esm directory. - const tokensDir = join( - process.cwd(), - 'node_modules', - '@patternfly', - 'react-tokens', - 'dist', - 'esm', - ) - - if (!existsSync(tokensDir)) { + let componentIndex: Record>> + try { + const module = await import( + '@patternfly/react-tokens/dist/esm/componentIndex' + ) + // Exclude default export - componentIndex exports named components (c_accordion, etc.) + componentIndex = Object.fromEntries( + Object.entries(module).filter(([key]) => key !== 'default'), + ) as unknown as Record< + string, + Record> + > + } catch { return [] } - // Get all files in the directory - const files = await readdir(tokensDir) - - // Filter for .js files that match any of the token prefixes - // Exclude componentIndex.js and main component files (like c_accordion.js without underscores after the prefix) - const matchingFiles = files.filter((file) => { - if (!file.endsWith('.js') || file === 'componentIndex.js') { - return false - } - // Check if file starts with any of the token prefixes - // We want individual token files (e.g., c_accordion__toggle_FontFamily.js) - // but not the main component index file (e.g., c_accordion.js) - return tokenPrefixes.some((prefix) => { - if (file === `${prefix}.js`) { - // This is the main component file, skip it - return false - } - return file.startsWith(prefix) - }) - }) - - // Import and extract objects from each matching file - const tokenObjects: { name: string; value: string; var: string }[] = [] - - await Promise.all( - matchingFiles.map(async (file) => { - const filePath = join(tokensDir, file) - const fileContent = await readFile(filePath, 'utf8') + // Get unique base components (e.g., c_accordion from c_accordion__expandable_content) + const baseComponents = new Set( + tokenPrefixes.map((p) => p.split('__')[0]), + ) - // Extract the exported object using regex - // Pattern: export const variableName = { "name": "...", "value": "...", "var": "..." }; - // Use non-greedy match to get just the first exported const object - const objectMatch = fileContent.match( - /export const \w+ = \{[\s\S]*?\n\};/, - ) + const tokensMap = new Map() - if (objectMatch) { - // Parse the object string to extract the JSON-like object - const objectContent = objectMatch[0] - .replace(/export const \w+ = /, '') - .replace(/;$/, '') + for (const baseComponent of baseComponents) { + const componentData = componentIndex[baseComponent] + if (!componentData || typeof componentData !== 'object') { + continue + } - // Parse as JSON - the token format uses double-quoted keys/values, so it's JSON-compatible. - // Avoids new Function()/eval which would execute arbitrary code from dependency files. - let tokenObject: { name: string; value: string | string[]; var: string } - try { - tokenObject = JSON.parse(objectContent) - } catch { - return // Skip malformed content - } + for (const selectorTokens of Object.values(componentData)) { + if (!selectorTokens || typeof selectorTokens !== 'object') { + continue + } + for (const [tokenKey, token] of Object.entries(selectorTokens)) { if ( - tokenObject && - typeof tokenObject === 'object' && - typeof tokenObject.name === 'string' && - (typeof tokenObject.value === 'string' || - (Array.isArray(tokenObject.value) && - tokenObject.value.every((v) => typeof v === 'string'))) && - typeof tokenObject.var === 'string' + !token || + typeof token !== 'object' || + typeof token.name !== 'string' || + typeof token.value !== 'string' ) { - const value = Array.isArray(tokenObject.value) - ? tokenObject.value.join(', ') - : tokenObject.value - tokenObjects.push({ - name: tokenObject.name, - value, - var: tokenObject.var, - }) + continue + } + + // Include token if its key starts with any of our token prefixes + if (!tokenPrefixes.some((prefix) => tokenKey.startsWith(prefix))) { + continue } + + tokensMap.set(token.name, { + name: token.name, + value: token.value, + var: `var(${token.name})`, + }) } - }), - ) + } + } - // Sort by name for consistent ordering - return tokenObjects.sort((a, b) => a.name.localeCompare(b.name)) + return Array.from(tokensMap.values()).sort((a, b) => + a.name.localeCompare(b.name), + ) } From a18ac3ce35c729549f4984ef044fb3db146d33bb Mon Sep 17 00:00:00 2001 From: Donald Labaj Date: Wed, 4 Feb 2026 16:59:33 -0500 Subject: [PATCH 09/10] chore: Fixed lint error. --- src/utils/__tests__/extractReactTokens.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/utils/__tests__/extractReactTokens.test.ts b/src/utils/__tests__/extractReactTokens.test.ts index e07e03b..baa33fb 100644 --- a/src/utils/__tests__/extractReactTokens.test.ts +++ b/src/utils/__tests__/extractReactTokens.test.ts @@ -1,3 +1,4 @@ +/* eslint-disable camelcase -- mock data mirrors @patternfly/react-tokens componentIndex structure */ import { extractReactTokens } from '../extractReactTokens' const mockComponentIndex: Record< From 667eb46df156f45943a07cdb00f21572ea695e88 Mon Sep 17 00:00:00 2001 From: Donald Labaj Date: Wed, 4 Feb 2026 17:33:46 -0500 Subject: [PATCH 10/10] fixed parsing error. --- src/utils/apiIndex/generate.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/utils/apiIndex/generate.ts b/src/utils/apiIndex/generate.ts index 7262870..7f88688 100644 --- a/src/utils/apiIndex/generate.ts +++ b/src/utils/apiIndex/generate.ts @@ -193,8 +193,9 @@ export async function generateApiIndex(): Promise { } // Collect CSS prefixes for pages - we'll extract tokens later - if (entry.data.cssPrefix && !pageCssPrefixes[pageKey]) { - pageCssPrefixes[pageKey] = entry.data.cssPrefix + // Key by version::section::page (tabKey) so each page gets its own tokens + if (entry.data.cssPrefix && !pageCssPrefixes[tabKey]) { + pageCssPrefixes[tabKey] = entry.data.cssPrefix } })