diff --git a/src/__tests__/pages/api/__tests__/[version]/tokens.test.ts b/src/__tests__/pages/api/__tests__/[version]/tokens.test.ts new file mode 100644 index 0000000..d442497 --- /dev/null +++ b/src/__tests__/pages/api/__tests__/[version]/tokens.test.ts @@ -0,0 +1,106 @@ +import { GET } from '../../../../../pages/api/[version]/tokens' + +const mockApiIndex = { + versions: ['v5', 'v6'], + sections: {}, + pages: {}, + tabs: {}, +} + +jest.mock('../../../../../utils/tokens', () => ({ + getTokenCategories: jest.fn(() => [ + 'c', + 'chart', + 'global', + 'hidden', + 'l', + 't', + ]), +})) + +it('returns sorted token categories for valid version', async () => { + global.fetch = jest.fn(() => + Promise.resolve({ + ok: true, + json: () => Promise.resolve(mockApiIndex), + } as Response), + ) + + const response = await GET({ + params: { version: 'v6' }, + url: new URL('http://localhost:4321/api/v6/tokens'), + } 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).toEqual(['c', 'chart', 'global', 'hidden', 'l', 't']) + + jest.restoreAllMocks() +}) + +it('returns categories alphabetically sorted', async () => { + global.fetch = jest.fn(() => + Promise.resolve({ + ok: true, + json: () => Promise.resolve(mockApiIndex), + } as Response), + ) + + const response = await GET({ + params: { version: 'v6' }, + url: new URL('http://localhost:4321/api/v6/tokens'), + } as any) + const body = await response.json() + + const sorted = [...body].sort() + expect(body).toEqual(sorted) + + jest.restoreAllMocks() +}) + +it('returns 404 error for nonexistent version', async () => { + global.fetch = jest.fn(() => + Promise.resolve({ + ok: true, + json: () => Promise.resolve(mockApiIndex), + } as Response), + ) + + const response = await GET({ + params: { version: 'v99' }, + url: new URL('http://localhost:4321/api/v99/tokens'), + } as any) + const body = await response.json() + + expect(response.status).toBe(404) + expect(body).toHaveProperty('error') + expect(body.error).toContain('v99') + expect(body.error).toContain('not found') + + jest.restoreAllMocks() +}) + +it('returns 400 error when version parameter is missing', async () => { + global.fetch = jest.fn(() => + Promise.resolve({ + ok: true, + json: () => Promise.resolve(mockApiIndex), + } as Response), + ) + + const response = await GET({ + params: {}, + url: new URL('http://localhost:4321/api/tokens'), + } as any) + const body = await response.json() + + expect(response.status).toBe(400) + expect(body).toHaveProperty('error') + expect(body.error).toContain('Version parameter is required') + + jest.restoreAllMocks() +}) diff --git a/src/__tests__/pages/api/__tests__/[version]/tokens/[category].test.ts b/src/__tests__/pages/api/__tests__/[version]/tokens/[category].test.ts new file mode 100644 index 0000000..3dad238 --- /dev/null +++ b/src/__tests__/pages/api/__tests__/[version]/tokens/[category].test.ts @@ -0,0 +1,201 @@ +import { GET } from '../../../../../../pages/api/[version]/tokens/[category]' + +const mockApiIndex = { + versions: ['v5', 'v6'], + sections: {}, + pages: {}, + tabs: {}, +} + +const mockTokens = { + c: [ + { + name: '--pf-v6-c-alert--Color', + value: '#000', + var: 'var(--pf-v6-c-alert--Color)', + }, + { + name: '--pf-v6-c-button--Color', + value: '#fff', + var: 'var(--pf-v6-c-button--Color)', + }, + ], + t: [ + { + name: '--pf-v6-t-global--Color', + value: '#333', + var: 'var(--pf-v6-t-global--Color)', + }, + ], +} + +jest.mock('../../../../../../utils/tokens', () => ({ + getTokenCategories: jest.fn(() => ['c', 't']), + getTokensForCategory: jest.fn( + (category: string) => mockTokens[category as keyof typeof mockTokens], + ), + filterTokens: jest.fn((tokens, filter) => + tokens.filter((token: any) => + token.name.toLowerCase().includes(filter.toLowerCase()), + ), + ), +})) + +it('returns tokens for valid category', async () => { + global.fetch = jest.fn(() => + Promise.resolve({ + ok: true, + json: () => Promise.resolve(mockApiIndex), + } as Response), + ) + + const response = await GET({ + params: { version: 'v6', category: 'c' }, + url: new URL('http://localhost:4321/api/v6/tokens/c'), + } 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') + + jest.restoreAllMocks() +}) + +it('filters tokens when filter parameter is provided', async () => { + global.fetch = jest.fn(() => + Promise.resolve({ + ok: true, + json: () => Promise.resolve(mockApiIndex), + } as Response), + ) + + const response = await GET({ + params: { version: 'v6', category: 'c' }, + url: new URL('http://localhost:4321/api/v6/tokens/c?filter=alert'), + } as any) + const body = await response.json() + + expect(response.status).toBe(200) + expect(Array.isArray(body)).toBe(true) + expect(body).toHaveLength(1) + expect(body[0].name).toContain('alert') + + jest.restoreAllMocks() +}) + +it('returns empty array when filter yields no matches', async () => { + global.fetch = jest.fn(() => + Promise.resolve({ + ok: true, + json: () => Promise.resolve(mockApiIndex), + } as Response), + ) + + const response = await GET({ + params: { version: 'v6', category: 'c' }, + url: new URL('http://localhost:4321/api/v6/tokens/c?filter=nonexistent'), + } as any) + const body = await response.json() + + expect(response.status).toBe(200) + expect(Array.isArray(body)).toBe(true) + expect(body).toHaveLength(0) + + jest.restoreAllMocks() +}) + +it('returns 404 error for invalid category with valid categories list', async () => { + global.fetch = jest.fn(() => + Promise.resolve({ + ok: true, + json: () => Promise.resolve(mockApiIndex), + } as Response), + ) + + const response = await GET({ + params: { version: 'v6', category: 'invalid' }, + url: new URL('http://localhost:4321/api/v6/tokens/invalid'), + } as any) + const body = await response.json() + + expect(response.status).toBe(404) + expect(body).toHaveProperty('error') + expect(body.error).toContain('invalid') + expect(body.error).toContain('not found') + expect(body).toHaveProperty('validCategories') + expect(Array.isArray(body.validCategories)).toBe(true) + expect(body.validCategories).toContain('c') + expect(body.validCategories).toContain('t') + + jest.restoreAllMocks() +}) + +it('returns 404 error for nonexistent version', async () => { + global.fetch = jest.fn(() => + Promise.resolve({ + ok: true, + json: () => Promise.resolve(mockApiIndex), + } as Response), + ) + + const response = await GET({ + params: { version: 'v99', category: 'c' }, + url: new URL('http://localhost:4321/api/v99/tokens/c'), + } as any) + const body = await response.json() + + expect(response.status).toBe(404) + expect(body).toHaveProperty('error') + expect(body.error).toContain('v99') + + jest.restoreAllMocks() +}) + +it('returns 400 error when parameters are missing', async () => { + global.fetch = jest.fn(() => + Promise.resolve({ + ok: true, + json: () => Promise.resolve(mockApiIndex), + } as Response), + ) + + const response = await GET({ + params: {}, + url: new URL('http://localhost:4321/api/tokens/'), + } as any) + const body = await response.json() + + expect(response.status).toBe(400) + expect(body).toHaveProperty('error') + expect(body.error).toContain('required') + + jest.restoreAllMocks() +}) + +it('filter is case-insensitive', async () => { + global.fetch = jest.fn(() => + Promise.resolve({ + ok: true, + json: () => Promise.resolve(mockApiIndex), + } as Response), + ) + + const response = await GET({ + params: { version: 'v6', category: 'c' }, + url: new URL('http://localhost:4321/api/v6/tokens/c?filter=ALERT'), + } as any) + const body = await response.json() + + expect(response.status).toBe(200) + expect(Array.isArray(body)).toBe(true) + expect(body).toHaveLength(1) + + jest.restoreAllMocks() +}) diff --git a/src/__tests__/pages/api/__tests__/[version]/tokens/all.test.ts b/src/__tests__/pages/api/__tests__/[version]/tokens/all.test.ts new file mode 100644 index 0000000..760de5a --- /dev/null +++ b/src/__tests__/pages/api/__tests__/[version]/tokens/all.test.ts @@ -0,0 +1,224 @@ +import { GET } from '../../../../../../pages/api/[version]/tokens/all' + +const mockApiIndex = { + versions: ['v5', 'v6'], + sections: {}, + pages: {}, + tabs: {}, +} + +const mockTokensByCategory = { + c: [ + { + name: '--pf-v6-c-alert--Color', + value: '#000', + var: 'var(--pf-v6-c-alert--Color)', + }, + { + name: '--pf-v6-c-button--Color', + value: '#fff', + var: 'var(--pf-v6-c-button--Color)', + }, + ], + t: [ + { + name: '--pf-v6-t-global--Color', + value: '#333', + var: 'var(--pf-v6-t-global--Color)', + }, + ], + chart: [ + { + name: '--pf-v6-chart-global--Color', + value: '#666', + var: 'var(--pf-v6-chart-global--Color)', + }, + ], +} + +jest.mock('../../../../../../utils/tokens', () => ({ + getTokensByCategory: jest.fn(() => mockTokensByCategory), + filterTokensByCategory: jest.fn((byCategory, filter) => { + const filtered: any = {} + for (const [category, tokens] of Object.entries(byCategory)) { + const filteredTokens = (tokens as any[]).filter((token: any) => + token.name.toLowerCase().includes(filter.toLowerCase()), + ) + if (filteredTokens.length > 0) { + filtered[category] = filteredTokens + } + } + return filtered + }), +})) + +it('returns all tokens grouped by category', async () => { + global.fetch = jest.fn(() => + Promise.resolve({ + ok: true, + json: () => Promise.resolve(mockApiIndex), + } as Response), + ) + + const response = await GET({ + params: { version: 'v6' }, + url: new URL('http://localhost:4321/api/v6/tokens/all'), + } 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(typeof body).toBe('object') + expect(body).toHaveProperty('c') + expect(body).toHaveProperty('t') + expect(body).toHaveProperty('chart') + expect(Array.isArray(body.c)).toBe(true) + expect(body.c).toHaveLength(2) + expect(body.t).toHaveLength(1) + + jest.restoreAllMocks() +}) + +it('filters tokens across all categories when filter parameter is provided', async () => { + global.fetch = jest.fn(() => + Promise.resolve({ + ok: true, + json: () => Promise.resolve(mockApiIndex), + } as Response), + ) + + const response = await GET({ + params: { version: 'v6' }, + url: new URL('http://localhost:4321/api/v6/tokens/all?filter=alert'), + } as any) + const body = await response.json() + + expect(response.status).toBe(200) + expect(typeof body).toBe('object') + expect(body).toHaveProperty('c') + expect(body.c).toHaveLength(1) + expect(body.c[0].name).toContain('alert') + expect(body).not.toHaveProperty('t') + expect(body).not.toHaveProperty('chart') + + jest.restoreAllMocks() +}) + +it('returns empty object when filter yields no matches', async () => { + global.fetch = jest.fn(() => + Promise.resolve({ + ok: true, + json: () => Promise.resolve(mockApiIndex), + } as Response), + ) + + const response = await GET({ + params: { version: 'v6' }, + url: new URL('http://localhost:4321/api/v6/tokens/all?filter=nonexistent'), + } as any) + const body = await response.json() + + expect(response.status).toBe(200) + expect(typeof body).toBe('object') + expect(Object.keys(body)).toHaveLength(0) + + jest.restoreAllMocks() +}) + +it('filter is case-insensitive', async () => { + global.fetch = jest.fn(() => + Promise.resolve({ + ok: true, + json: () => Promise.resolve(mockApiIndex), + } as Response), + ) + + const response = await GET({ + params: { version: 'v6' }, + url: new URL('http://localhost:4321/api/v6/tokens/all?filter=GLOBAL'), + } as any) + const body = await response.json() + + expect(response.status).toBe(200) + expect(typeof body).toBe('object') + expect(Object.keys(body).length).toBeGreaterThan(0) + + jest.restoreAllMocks() +}) + +it('returns 404 error for nonexistent version', async () => { + global.fetch = jest.fn(() => + Promise.resolve({ + ok: true, + json: () => Promise.resolve(mockApiIndex), + } as Response), + ) + + const response = await GET({ + params: { version: 'v99' }, + url: new URL('http://localhost:4321/api/v99/tokens/all'), + } as any) + const body = await response.json() + + expect(response.status).toBe(404) + expect(body).toHaveProperty('error') + expect(body.error).toContain('v99') + expect(body.error).toContain('not found') + + jest.restoreAllMocks() +}) + +it('returns 400 error when version parameter is missing', async () => { + global.fetch = jest.fn(() => + Promise.resolve({ + ok: true, + json: () => Promise.resolve(mockApiIndex), + } as Response), + ) + + const response = await GET({ + params: {}, + url: new URL('http://localhost:4321/api/tokens/all'), + } as any) + const body = await response.json() + + expect(response.status).toBe(400) + expect(body).toHaveProperty('error') + expect(body.error).toContain('Version parameter is required') + + jest.restoreAllMocks() +}) + +it('each token has required properties', async () => { + global.fetch = jest.fn(() => + Promise.resolve({ + ok: true, + json: () => Promise.resolve(mockApiIndex), + } as Response), + ) + + const response = await GET({ + params: { version: 'v6' }, + url: new URL('http://localhost:4321/api/v6/tokens/all'), + } as any) + const body = await response.json() + + expect(response.status).toBe(200) + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + for (const [_category, tokens] of Object.entries(body)) { + expect(Array.isArray(tokens)).toBe(true) + for (const token of tokens as any[]) { + expect(token).toHaveProperty('name') + expect(token).toHaveProperty('value') + expect(token).toHaveProperty('var') + expect(typeof token.name).toBe('string') + expect(typeof token.value).toBe('string') + expect(typeof token.var).toBe('string') + } + } + + jest.restoreAllMocks() +}) diff --git a/src/__tests__/utils/tokens.test.ts b/src/__tests__/utils/tokens.test.ts new file mode 100644 index 0000000..352c063 --- /dev/null +++ b/src/__tests__/utils/tokens.test.ts @@ -0,0 +1,328 @@ +jest.mock('@patternfly/react-tokens', () => ({ + // eslint-disable-next-line camelcase + c_alert_BackgroundColor: { + name: '--pf-v6-c-alert--BackgroundColor', + value: '#ffffff', + var: 'var(--pf-v6-c-alert--BackgroundColor)', + }, + // eslint-disable-next-line camelcase + c_alert_Color: { + name: '--pf-v6-c-alert--Color', + value: '#000000', + var: 'var(--pf-v6-c-alert--Color)', + }, + // eslint-disable-next-line camelcase + c_button_BackgroundColor: { + name: '--pf-v6-c-button--BackgroundColor', + value: '#0066cc', + var: 'var(--pf-v6-c-button--BackgroundColor)', + }, + // eslint-disable-next-line camelcase + t_global_color_100: { + name: '--pf-v6-t-global--color--100', + value: '#f0f0f0', + var: 'var(--pf-v6-t-global--color--100)', + }, + // eslint-disable-next-line camelcase + t_global_color_200: { + name: '--pf-t-global--color--200', + value: '#e0e0e0', + var: 'var(--pf-t-global--color--200)', + }, + // eslint-disable-next-line camelcase + chart_global_Fill: { + name: '--pf-v6-chart-global--Fill', + value: '#06c', + var: 'var(--pf-v6-chart-global--Fill)', + }, + // eslint-disable-next-line camelcase + l_grid_gutter: { + name: '--pf-v6-l-grid--gutter', + value: '1rem', + var: 'var(--pf-v6-l-grid--gutter)', + }, + invalidToken: { + name: '--invalid', + }, + default: 'should be ignored', +})) + +import { + getAllTokens, + getTokenCategories, + getTokensByCategory, + getTokensForCategory, + filterTokens, + filterTokensByCategory, +} from '../../utils/tokens' + +describe('getAllTokens', () => { + it('returns all valid tokens', () => { + const tokens = getAllTokens() + + expect(Array.isArray(tokens)).toBe(true) + expect(tokens.length).toBe(7) // 7 valid tokens in mockTokens + }) + + it('each token has required properties', () => { + const tokens = getAllTokens() + + tokens.forEach((token) => { + expect(token).toHaveProperty('name') + expect(token).toHaveProperty('value') + expect(token).toHaveProperty('var') + expect(typeof token.name).toBe('string') + expect(typeof token.value).toBe('string') + expect(typeof token.var).toBe('string') + }) + }) + + it('filters out invalid tokens', () => { + const tokens = getAllTokens() + + // Should not include the invalid token or non-object exports + const tokenNames = tokens.map((t) => t.name) + expect(tokenNames).not.toContain('--invalid') + }) + + it('returns cached tokens on subsequent calls', () => { + const tokens1 = getAllTokens() + const tokens2 = getAllTokens() + + // Should return the same array reference (cached) + expect(tokens1).toBe(tokens2) + }) +}) + +describe('getTokenCategories', () => { + it('returns sorted array of categories', () => { + const categories = getTokenCategories() + + expect(Array.isArray(categories)).toBe(true) + expect(categories).toEqual(['c', 'chart', 'l', 't']) + }) + + it('categories are alphabetically sorted', () => { + const categories = getTokenCategories() + const sorted = [...categories].sort() + + expect(categories).toEqual(sorted) + }) + + it('categories are unique', () => { + const categories = getTokenCategories() + const unique = [...new Set(categories)] + + expect(categories).toEqual(unique) + }) + + it('returns cached categories on subsequent calls', () => { + const categories1 = getTokenCategories() + const categories2 = getTokenCategories() + + expect(categories1).toBe(categories2) + }) +}) + +describe('getTokensByCategory', () => { + it('returns object with category keys', () => { + const byCategory = getTokensByCategory() + + expect(typeof byCategory).toBe('object') + expect(byCategory).toHaveProperty('c') + expect(byCategory).toHaveProperty('t') + expect(byCategory).toHaveProperty('chart') + expect(byCategory).toHaveProperty('l') + }) + + it('groups tokens correctly by category', () => { + const byCategory = getTokensByCategory() + + expect(byCategory.c).toHaveLength(3) // c_alert_BackgroundColor, c_alert_Color, c_button_BackgroundColor + expect(byCategory.t).toHaveLength(2) // t_global_color_100, t_global_color_200 + expect(byCategory.chart).toHaveLength(1) // chart_global_Fill + expect(byCategory.l).toHaveLength(1) // l_grid_gutter + }) + + it('all tokens in a category have correct prefix', () => { + const byCategory = getTokensByCategory() + + Object.entries(byCategory).forEach(([category, tokens]) => { + tokens.forEach((token) => { + const isVersionedToken = /^--pf-v6/.test(token.name) + const prefix = token.name.split('-')[isVersionedToken ? 4 : 3] // --pf-v6-{prefix}- if versioned or --pf-{prefix}- if unversioned + expect(prefix).toBe(category) + }) + }) + }) + + it('returns cached result on subsequent calls', () => { + const byCategory1 = getTokensByCategory() + const byCategory2 = getTokensByCategory() + + expect(byCategory1).toBe(byCategory2) + }) +}) + +describe('getTokensForCategory', () => { + it('returns tokens for valid category', () => { + const cTokens = getTokensForCategory('c') + + expect(Array.isArray(cTokens)).toBe(true) + expect(cTokens).toHaveLength(3) + }) + + it('returns undefined for invalid category', () => { + const tokens = getTokensForCategory('invalid') + + expect(tokens).toBeUndefined() + }) + + it('returns correct tokens for each category', () => { + const cTokens = getTokensForCategory('c') + const tTokens = getTokensForCategory('t') + const chartTokens = getTokensForCategory('chart') + const lTokens = getTokensForCategory('l') + + expect(cTokens?.length).toBe(3) + expect(tTokens?.length).toBe(2) + expect(chartTokens?.length).toBe(1) + expect(lTokens?.length).toBe(1) + }) +}) + +describe('filterTokens', () => { + const testTokens = [ + { + name: '--pf-v6-c-alert--BackgroundColor', + value: '#fff', + var: 'var(--pf-v6-c-alert--BackgroundColor)', + }, + { + name: '--pf-v6-c-button--Color', + value: '#000', + var: 'var(--pf-v6-c-button--Color)', + }, + { + name: '--pf-v6-c-card--BackgroundColor', + value: '#f5f5f5', + var: 'var(--pf-v6-c-card--BackgroundColor)', + }, + ] + + it('returns all tokens when filter is empty', () => { + const filtered = filterTokens(testTokens, '') + + expect(filtered).toEqual(testTokens) + }) + + it('filters tokens by substring match', () => { + const filtered = filterTokens(testTokens, 'alert') + + expect(filtered).toHaveLength(1) + expect(filtered[0].name).toContain('alert') + }) + + it('filter is case-insensitive', () => { + const filtered = filterTokens(testTokens, 'ALERT') + + expect(filtered).toHaveLength(1) + expect(filtered[0].name).toContain('alert') + }) + + it('returns multiple matches', () => { + const filtered = filterTokens(testTokens, 'BackgroundColor') + + expect(filtered).toHaveLength(2) + filtered.forEach((token) => { + expect(token.name).toContain('BackgroundColor') + }) + }) + + it('returns empty array when no matches', () => { + const filtered = filterTokens(testTokens, 'nonexistent') + + expect(filtered).toHaveLength(0) + }) + + it('handles partial matches', () => { + const filtered = filterTokens(testTokens, 'c-c') + + expect(filtered).toHaveLength(1) + expect(filtered[0].name).toContain('c-card') + }) +}) + +describe('filterTokensByCategory', () => { + const testTokensByCategory = { + c: [ + { + name: '--pf-v6-c-alert--Color', + value: '#000', + var: 'var(--pf-v6-c-alert--Color)', + }, + { + name: '--pf-v6-c-button--Color', + value: '#fff', + var: 'var(--pf-v6-c-button--Color)', + }, + ], + t: [ + { + name: '--pf-v6-t-global--color--100', + value: '#f0f0f0', + var: 'var(--pf-v6-t-global--color--100)', + }, + ], + chart: [ + { + name: '--pf-v6-chart-global--Fill', + value: '#06c', + var: 'var(--pf-v6-chart-global--Fill)', + }, + ], + } + + it('returns all categories when filter is empty', () => { + const filtered = filterTokensByCategory(testTokensByCategory, '') + + expect(filtered).toEqual(testTokensByCategory) + }) + + it('filters across all categories', () => { + const filtered = filterTokensByCategory(testTokensByCategory, 'alert') + + expect(Object.keys(filtered)).toHaveLength(1) + expect(filtered).toHaveProperty('c') + expect(filtered.c).toHaveLength(1) + expect(filtered.c[0].name).toContain('alert') + }) + + it('excludes categories with no matches', () => { + const filtered = filterTokensByCategory(testTokensByCategory, 'button') + + expect(Object.keys(filtered)).toHaveLength(1) + expect(filtered).toHaveProperty('c') + expect(filtered).not.toHaveProperty('t') + expect(filtered).not.toHaveProperty('chart') + }) + + it('returns empty object when no matches', () => { + const filtered = filterTokensByCategory(testTokensByCategory, 'nonexistent') + + expect(Object.keys(filtered)).toHaveLength(0) + }) + + it('filter is case-insensitive', () => { + const filtered = filterTokensByCategory(testTokensByCategory, 'GLOBAL') + + expect(Object.keys(filtered).length).toBeGreaterThan(0) + }) + + it('can match tokens in multiple categories', () => { + const filtered = filterTokensByCategory(testTokensByCategory, 'global') + + expect(filtered).toHaveProperty('t') + expect(filtered).toHaveProperty('chart') + }) +}) diff --git a/src/pages/api/[version]/tokens.ts b/src/pages/api/[version]/tokens.ts new file mode 100644 index 0000000..4039426 --- /dev/null +++ b/src/pages/api/[version]/tokens.ts @@ -0,0 +1,32 @@ +import type { APIRoute } from 'astro' +import { createJsonResponse } from '../../../utils/apiHelpers' +import { fetchApiIndex } from '../../../utils/apiIndex/fetch' +import { getTokenCategories } from '../../../utils/tokens' + +export const prerender = false + +export const GET: APIRoute = async ({ params, url }) => { + const { version } = params + + if (!version) { + return createJsonResponse({ error: 'Version parameter is required' }, 400) + } + + try { + const index = await fetchApiIndex(url) + if (!index.versions.includes(version)) { + return createJsonResponse( + { error: `Version '${version}' not found` }, + 404, + ) + } + + const categories = getTokenCategories() + return createJsonResponse(categories) + } catch (error) { + return createJsonResponse( + { error: 'Failed to load tokens', details: error }, + 500, + ) + } +} diff --git a/src/pages/api/[version]/tokens/[category].ts b/src/pages/api/[version]/tokens/[category].ts new file mode 100644 index 0000000..e692116 --- /dev/null +++ b/src/pages/api/[version]/tokens/[category].ts @@ -0,0 +1,56 @@ +import type { APIRoute } from 'astro' +import { createJsonResponse } from '../../../../utils/apiHelpers' +import { fetchApiIndex } from '../../../../utils/apiIndex/fetch' +import { + getTokenCategories, + getTokensForCategory, + filterTokens, +} from '../../../../utils/tokens' + +export const prerender = false + +export const GET: APIRoute = async ({ params, url }) => { + const { version, category } = params + + if (!version || !category) { + return createJsonResponse( + { error: 'Version and category parameters are required' }, + 400, + ) + } + + try { + const index = await fetchApiIndex(url) + if (!index.versions.includes(version)) { + return createJsonResponse( + { error: `Version '${version}' not found` }, + 404, + ) + } + + const tokens = getTokensForCategory(category) + + if (!tokens) { + const validCategories = getTokenCategories() + return createJsonResponse( + { + error: `Category '${category}' not found`, + validCategories, + }, + 404, + ) + } + + const filterParam = url.searchParams.get('filter') + const filteredTokens = filterParam + ? filterTokens(tokens, filterParam) + : tokens + + return createJsonResponse(filteredTokens) + } catch (error) { + return createJsonResponse( + { error: 'Failed to load tokens', details: error }, + 500, + ) + } +} diff --git a/src/pages/api/[version]/tokens/all.ts b/src/pages/api/[version]/tokens/all.ts new file mode 100644 index 0000000..bd19e75 --- /dev/null +++ b/src/pages/api/[version]/tokens/all.ts @@ -0,0 +1,41 @@ +import type { APIRoute } from 'astro' +import { createJsonResponse } from '../../../../utils/apiHelpers' +import { fetchApiIndex } from '../../../../utils/apiIndex/fetch' +import { + getTokensByCategory, + filterTokensByCategory, +} from '../../../../utils/tokens' + +export const prerender = false + +export const GET: APIRoute = async ({ params, url }) => { + const { version } = params + + if (!version) { + return createJsonResponse({ error: 'Version parameter is required' }, 400) + } + + try { + const index = await fetchApiIndex(url) + if (!index.versions.includes(version)) { + return createJsonResponse( + { error: `Version '${version}' not found` }, + 404, + ) + } + + const tokensByCategory = getTokensByCategory() + + const filterParam = url.searchParams.get('filter') + const filteredTokens = filterParam + ? filterTokensByCategory(tokensByCategory, filterParam) + : tokensByCategory + + return createJsonResponse(filteredTokens) + } catch (error) { + return createJsonResponse( + { error: 'Failed to load tokens', details: error }, + 500, + ) + } +} diff --git a/src/pages/api/openapi.json.ts b/src/pages/api/openapi.json.ts index 0262f0d..14483e6 100644 --- a/src/pages/api/openapi.json.ts +++ b/src/pages/api/openapi.json.ts @@ -641,6 +641,252 @@ export const GET: APIRoute = async ({ url }) => { }, }, }, + '/{version}/tokens': { + get: { + summary: 'List token categories', + description: + 'Returns an alphabetically sorted array of available design token categories from @patternfly/react-tokens. Categories are determined by token name prefixes (e.g., c_, t_, chart_). Optimized for MCP/LLM consumption.', + operationId: 'getTokenCategories', + parameters: [ + { + name: 'version', + in: 'path', + required: true, + description: 'Documentation version', + schema: { + type: 'string', + enum: versions, + }, + example: 'v6', + }, + ], + responses: { + '200': { + description: 'List of token categories', + content: { + 'application/json': { + schema: { + type: 'array', + items: { + type: 'string', + }, + }, + example: ['c', 'chart', 'global', 'hidden', 'l', 't'], + }, + }, + }, + '404': { + description: 'Version not found', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + error: { + type: 'string', + }, + }, + }, + }, + }, + }, + }, + }, + }, + '/{version}/tokens/{category}': { + get: { + summary: 'Get tokens for a category', + description: + 'Returns design tokens for a specific category with optional filtering. Each token includes name (CSS variable name), value (resolved value), and var (CSS var() reference). Use the filter query parameter for case-insensitive substring matching to minimize response size.', + operationId: 'getTokensByCategory', + parameters: [ + { + name: 'version', + in: 'path', + required: true, + description: 'Documentation version', + schema: { + type: 'string', + enum: versions, + }, + example: 'v6', + }, + { + name: 'category', + in: 'path', + required: true, + description: 'Token category (e.g., c, t, chart)', + schema: { + type: 'string', + }, + example: 'c', + }, + { + name: 'filter', + in: 'query', + required: false, + description: 'Case-insensitive substring filter to match against token names', + schema: { + type: 'string', + }, + example: 'alert', + }, + ], + responses: { + '200': { + description: 'Array of tokens matching the criteria', + content: { + 'application/json': { + schema: { + type: 'array', + items: { + type: 'object', + properties: { + name: { + type: 'string', + description: 'CSS variable name', + }, + value: { + type: 'string', + description: 'Resolved CSS value', + }, + var: { + type: 'string', + description: 'CSS var() reference', + }, + }, + required: ['name', 'value', 'var'], + }, + }, + example: [ + { + name: '--pf-v6-c-alert--Color', + value: '#000', + var: 'var(--pf-v6-c-alert--Color)', + }, + ], + }, + }, + }, + '404': { + description: 'Category not found', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + error: { + type: 'string', + }, + validCategories: { + type: 'array', + items: { + type: 'string', + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + '/{version}/tokens/all': { + get: { + summary: 'Get all tokens grouped by category', + description: + 'Returns all design tokens organized by category with optional filtering. Use the filter query parameter to minimize response size for MCP/LLM consumption. Empty categories are excluded from filtered results.', + operationId: 'getAllTokens', + parameters: [ + { + name: 'version', + in: 'path', + required: true, + description: 'Documentation version', + schema: { + type: 'string', + enum: versions, + }, + example: 'v6', + }, + { + name: 'filter', + in: 'query', + required: false, + description: 'Case-insensitive substring filter to match against token names across all categories', + schema: { + type: 'string', + }, + example: 'color', + }, + ], + responses: { + '200': { + description: 'Object with category keys and token arrays as values', + content: { + 'application/json': { + schema: { + type: 'object', + additionalProperties: { + type: 'array', + items: { + type: 'object', + properties: { + name: { + type: 'string', + description: 'CSS variable name', + }, + value: { + type: 'string', + description: 'Resolved CSS value', + }, + var: { + type: 'string', + description: 'CSS var() reference', + }, + }, + required: ['name', 'value', 'var'], + }, + }, + }, + example: { + c: [ + { + name: '--pf-v6-c-alert--Color', + value: '#000', + var: 'var(--pf-v6-c-alert--Color)', + }, + ], + t: [ + { + name: '--pf-v6-t-global--Color', + value: '#333', + var: 'var(--pf-v6-t-global--Color)', + }, + ], + }, + }, + }, + }, + '404': { + description: 'Version not found', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + error: { + type: 'string', + }, + }, + }, + }, + }, + }, + }, + }, + }, }, tags: [ { diff --git a/src/utils/tokens.ts b/src/utils/tokens.ts new file mode 100644 index 0000000..50aeda9 --- /dev/null +++ b/src/utils/tokens.ts @@ -0,0 +1,123 @@ +import * as allTokens from '@patternfly/react-tokens' + +export interface Token { + name: string + value: string + var: string +} + +export interface TokensByCategory { + [category: string]: Token[] +} + +let cachedTokens: Token[] | null = null +let cachedCategories: string[] | null = null +let cachedTokensByCategory: TokensByCategory | null = null + +function getCategoryFromTokenName(tokenName: string): string { + const nameWithoutPfPrefix = tokenName.replace(/^--pf-/, '') + const parts = nameWithoutPfPrefix.split(/-+/) + if (/^v\d+/.test(parts[0])) { + return parts[1] + } + + return parts[0] +} + +export function getAllTokens(): Token[] { + if (cachedTokens) { + return cachedTokens + } + + const tokens: Token[] = [] + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + for (const [_exportName, tokenValue] of Object.entries(allTokens)) { + if (typeof tokenValue === 'object' && tokenValue !== null) { + const token = tokenValue as Token + + if (token.name && token.value && token.var) { + tokens.push({ + name: token.name, + value: token.value, + var: token.var, + }) + } + } + } + + cachedTokens = tokens + return tokens +} + +export function getTokenCategories(): string[] { + if (cachedCategories) { + return cachedCategories + } + + const tokens = getAllTokens() + const categorySet = new Set() + + for (const token of tokens) { + const category = getCategoryFromTokenName(token.name) + categorySet.add(category) + } + + cachedCategories = Array.from(categorySet).sort() + return cachedCategories +} + +export function getTokensByCategory(): TokensByCategory { + if (cachedTokensByCategory) { + return cachedTokensByCategory + } + + const tokens = getAllTokens() + const byCategory: TokensByCategory = {} + + for (const token of tokens) { + const category = getCategoryFromTokenName(token.name) + if (!byCategory[category]) { + byCategory[category] = [] + } + byCategory[category].push(token) + } + + cachedTokensByCategory = byCategory + return byCategory +} + +export function getTokensForCategory(category: string): Token[] | undefined { + const byCategory = getTokensByCategory() + return byCategory[category] +} + +export function filterTokens(tokens: Token[], filter: string): Token[] { + if (!filter) { + return tokens + } + + const lowerFilter = filter.toLowerCase() + return tokens.filter((token) => + token.name.toLowerCase().includes(lowerFilter), + ) +} + +export function filterTokensByCategory( + byCategory: TokensByCategory, + filter: string, +): TokensByCategory { + if (!filter) { + return byCategory + } + + const filtered: TokensByCategory = {} + for (const [category, tokens] of Object.entries(byCategory)) { + const filteredTokens = filterTokens(tokens, filter) + if (filteredTokens.length > 0) { + filtered[category] = filteredTokens + } + } + + return filtered +}