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..5638503 --- /dev/null +++ b/src/__tests__/pages/api/__tests__/[version]/[section]/[page]/css.test.ts @@ -0,0 +1,238 @@ +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', + 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', + }, + ], + 'v6::components::button': [ + { + name: '--pf-v6-c-button--BackgroundColor', + value: '#0066cc', + var: '--pf-v6-c-button--BackgroundColor', + 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('var') + 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 empty array when no CSS tokens are found for page', 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(200) + expect(Array.isArray(body)).toBe(true) + expect(body).toHaveLength(0) +}) + +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(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 new file mode 100644 index 0000000..bdd1dba --- /dev/null +++ b/src/pages/api/[version]/[section]/[page]/css.ts @@ -0,0 +1,34 @@ +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([]) + } + + 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..ae1abeb 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: [ + { name: 'c_alert__Background', value: '#000000', var: '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..baa33fb --- /dev/null +++ b/src/utils/__tests__/extractReactTokens.test.ts @@ -0,0 +1,198 @@ +/* eslint-disable camelcase -- mock data mirrors @patternfly/react-tokens componentIndex structure */ +import { extractReactTokens } from '../extractReactTokens' + +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.resetModules() + }) + + describe('CSS prefix to token prefix conversion', () => { + it('converts single CSS prefix correctly', async () => { + const result = await extractReactTokens('pf-v6-c-accordion') + + 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 () => { + const result = await extractReactTokens([ + 'pf-v6-c-accordion', + 'pf-v6-c-button', + ]) + + 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 () => { + const result = await extractReactTokens('c-accordion') + + expect(result.length).toBeGreaterThan(0) + }) + }) + + 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([]) + }) + + it('extracts tokens from componentIndex', async () => { + const result = await extractReactTokens('pf-v6-c-accordion') + + 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('token filtering', () => { + it('filters tokens by prefix for subcomponents', async () => { + const result = await extractReactTokens( + 'pf-v6-c-accordion__expandable-content', + ) + + 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 returns union of tokens', async () => { + const result = await extractReactTokens([ + 'pf-v6-c-accordion', + 'pf-v6-c-button', + ]) + + 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 files with no matching prefix', async () => { + const result = await extractReactTokens('pf-v6-c-accordion') + + const otherTokens = result.filter( + (t) => + !t.name.includes('accordion') && !t.name.includes('accordion'), + ) + expect(otherTokens).toHaveLength(0) + }) + }) + + describe('token extraction', () => { + it('extracts token object with correct format', async () => { + const result = await extractReactTokens('pf-v6-c-accordion') + + expect(result).toEqual( + expect.arrayContaining([ + { + name: '--pf-v6-c-accordion--toggle--FontFamily', + value: '1rem', + var: 'var(--pf-v6-c-accordion--toggle--FontFamily)', + }, + ]), + ) + }) + + it('deduplicates tokens that appear in multiple selectors', async () => { + const result = await extractReactTokens('pf-v6-c-accordion') + + const toggleFontFamily = result.filter( + (t) => t.name === '--pf-v6-c-accordion--toggle--FontFamily', + ) + expect(toggleFontFamily).toHaveLength(1) + }) + + it('validates token object has required properties', async () => { + const result = await extractReactTokens('pf-v6-c-accordion') + + expect(result.every((t) => t.name && t.value && t.var)).toBe(true) + }) + }) + + describe('sorting', () => { + it('sorts tokens by name alphabetically', async () => { + const result = await extractReactTokens('pf-v6-c-accordion') + + const sorted = [...result].sort((a, b) => a.name.localeCompare(b.name)) + expect(result).toEqual(sorted) + }) + }) + + describe('edge cases', () => { + it('handles empty component', async () => { + const result = await extractReactTokens('pf-v6-c-empty') + + expect(result).toEqual([]) + }) + + it('handles CSS prefix with multiple hyphens', async () => { + const result = await extractReactTokens('pf-v6-c-data-list') + + expect(result).toHaveLength(1) + expect(result[0].name).toContain('data-list') + }) + }) +}) diff --git a/src/utils/apiIndex/generate.ts b/src/utils/apiIndex/generate.ts index 19cc10e..7f88688 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,26 @@ export async function generateApiIndex(): Promise { if (examplesWithTitles.length > 0) { tabExamples[exampleKey] = examplesWithTitles } + + // Collect CSS prefixes for pages - we'll extract tokens later + // Key by version::section::page (tabKey) so each page gets its own tokens + if (entry.data.cssPrefix && !pageCssPrefixes[tabKey]) { + pageCssPrefixes[tabKey] = 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 +226,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..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 @@ -27,6 +28,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 +122,21 @@ 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 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..1b17c80 --- /dev/null +++ b/src/utils/extractReactTokens.ts @@ -0,0 +1,92 @@ +/** + * 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 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 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 + */ +export async function extractReactTokens( + cssPrefix: string | string[], +): Promise<{ name: string; value: string; var: string }[]> { + // Handle both single prefix and array of prefixes to support subcomponents. + const prefixes = Array.isArray(cssPrefix) ? cssPrefix : [cssPrefix] + const tokenPrefixes = prefixes.map(cssPrefixToTokenPrefix) + + 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 unique base components (e.g., c_accordion from c_accordion__expandable_content) + const baseComponents = new Set( + tokenPrefixes.map((p) => p.split('__')[0]), + ) + + const tokensMap = new Map() + + for (const baseComponent of baseComponents) { + const componentData = componentIndex[baseComponent] + if (!componentData || typeof componentData !== 'object') { + continue + } + + for (const selectorTokens of Object.values(componentData)) { + if (!selectorTokens || typeof selectorTokens !== 'object') { + continue + } + + for (const [tokenKey, token] of Object.entries(selectorTokens)) { + if ( + !token || + typeof token !== 'object' || + typeof token.name !== 'string' || + typeof token.value !== 'string' + ) { + 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})`, + }) + } + } + } + + return Array.from(tokensMap.values()).sort((a, b) => + a.name.localeCompare(b.name), + ) +}