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..278fbd1 --- /dev/null +++ b/src/__tests__/pages/api/__tests__/[version]/[section]/[page]/css.test.ts @@ -0,0 +1,239 @@ +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 () => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + 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 () => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + 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 () => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + 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') +}) 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 cead58f..6b5b94a 100644 --- a/src/pages/api/index.ts +++ b/src/pages/api/index.ts @@ -132,6 +132,42 @@ export const GET: APIRoute = async () => example: ['react', 'react-demos', 'html'], }, }, + { + 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..abf1517 --- /dev/null +++ b/src/utils/__tests__/extractReactTokens.test.ts @@ -0,0 +1,453 @@ +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() + }) + + 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(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" };', + ) + + 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" };', + ) + + 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) + }) + + 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([ + '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 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 4a92211..76a83ca 100644 --- a/src/utils/apiIndex/generate.ts +++ b/src/utils/apiIndex/generate.ts @@ -7,6 +7,7 @@ import { content } from '../../content' import { kebabCase, addDemosOrDeprecated } from '../index' import { getDefaultTabForApi } from '../packageUtils' import { getOutputDir } from '../getOutputDir' +import { extractReactTokens } from '../extractReactTokens' const SOURCE_ORDER: Record = { react: 1, @@ -45,6 +46,8 @@ export interface ApiIndex { tabs: Record /** Examples by version::section::page::tab with titles (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 } /** @@ -102,6 +105,7 @@ export async function generateApiIndex(): Promise { pages: {}, tabs: {}, examples: {}, + css: {}, } // Get all versions @@ -130,6 +134,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) => { if (!entry.data.section) { @@ -165,8 +171,25 @@ export async function generateApiIndex(): Promise { if (examplesWithTitles.length > 0) { tabExamples[tabKey] = 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 index.sections[version] = Array.from(sections).sort() @@ -181,6 +204,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..c25ffdb 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<{ 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 new file mode 100644 index 0000000..0f47ec2 --- /dev/null +++ b/src/utils/extractReactTokens.ts @@ -0,0 +1,112 @@ +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<{ 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) + + // Path to the react-tokens esm directory. + const tokensDir = join( + process.cwd(), + 'node_modules', + '@patternfly', + 'react-tokens', + 'dist', + 'esm', + ) + + if (!existsSync(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: { name: string; value: string; var: string }[] = [] + + 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\};/, + ) + + if (objectMatch) { + // Parse the object string to extract the JSON-like object + const objectContent = objectMatch[0] + .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 + } + + 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, + }) + } + } + }), + ) + + // Sort by name for consistent ordering + return tokenObjects.sort((a, b) => a.name.localeCompare(b.name)) +}