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/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..615be95 --- /dev/null +++ b/src/utils/tokens.ts @@ -0,0 +1,151 @@ +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 + +/** + * Extracts the category from a token name + * Categories are determined by the first prefix before underscore + * Examples: + * c_alert_Title -> c + * t_global_color -> t + * chart_color_blue -> chart + */ +function getCategoryFromTokenName(tokenName: string): string { + const firstUnderscore = tokenName.indexOf('_') + if (firstUnderscore === -1) { + return tokenName + } + return tokenName.substring(0, firstUnderscore) +} + +/** + * Loads all tokens from @patternfly/react-tokens + * Returns an array of token objects with { name, value, var } + */ +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 +} + +/** + * Gets a sorted array of unique token categories + */ +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 +} + +/** + * Gets all tokens organized by category + */ +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 +} + +/** + * Gets tokens for a specific category + * Returns undefined if category doesn't exist + */ +export function getTokensForCategory(category: string): Token[] | undefined { + const byCategory = getTokensByCategory() + return byCategory[category] +} + +/** + * Filters tokens by substring match (case-insensitive) + * Matches against the token name field + */ +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), + ) +} + +/** + * Filters tokens by category (case-insensitive) + * Matches against the category name + */ +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 +}