From 3b208636b65270cf26d2e3df5f18b7bb9d354abf Mon Sep 17 00:00:00 2001 From: Donald Labaj Date: Wed, 4 Feb 2026 12:45:44 -0500 Subject: [PATCH 01/13] feat: Added icon api endpoints. --- package-lock.json | 10 +++ package.json | 9 +- src/pages/api/icons/[iconName].ts | 47 ++++++++++ src/pages/api/icons/index.ts | 32 +++++++ src/pages/api/index.ts | 52 +++++++++++ src/pages/api/openapi.json.ts | 81 +++++++++++++++++ src/utils/apiHelpers.ts | 12 ++- src/utils/icons/reactIcons.ts | 144 ++++++++++++++++++++++++++++++ 8 files changed, 382 insertions(+), 5 deletions(-) create mode 100644 src/pages/api/icons/[iconName].ts create mode 100644 src/pages/api/icons/index.ts create mode 100644 src/utils/icons/reactIcons.ts diff --git a/package-lock.json b/package-lock.json index eb6c533..390441d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -35,6 +35,7 @@ "react-docgen": "^7.1.1", "react-dom": "^18.3.1", "react-error-boundary": "^6.0.0", + "react-icons": "^5.5.0", "sass": "^1.90.0", "typescript": "^5.9.2" }, @@ -21902,6 +21903,15 @@ "react": ">=16.13.1" } }, + "node_modules/react-icons": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.5.0.tgz", + "integrity": "sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw==", + "license": "MIT", + "peerDependencies": { + "react": "*" + } + }, "node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", diff --git a/package.json b/package.json index cf3fc79..c1fe88b 100644 --- a/package.json +++ b/package.json @@ -55,6 +55,7 @@ "@nanostores/react": "^0.8.4", "@patternfly/ast-helpers": "1.4.0-alpha.190", "@patternfly/patternfly": "^6.0.0", + "@patternfly/quickstarts": "^6.0.0", "@patternfly/react-code-editor": "^6.2.2", "@patternfly/react-core": "^6.0.0", "@patternfly/react-drag-drop": "^6.0.0", @@ -62,7 +63,6 @@ "@patternfly/react-styles": "^6.0.0", "@patternfly/react-table": "^6.0.0", "@patternfly/react-tokens": "^6.0.0", - "@patternfly/quickstarts": "^6.0.0", "@types/react": "^18.3.23", "@types/react-dom": "^18.3.7", "astro": "^5.15.9", @@ -74,6 +74,7 @@ "react-docgen": "^7.1.1", "react-dom": "^18.3.1", "react-error-boundary": "^6.0.0", + "react-icons": "^5.5.0", "sass": "^1.90.0", "typescript": "^5.9.2" }, @@ -82,6 +83,8 @@ "@babel/preset-react": "^7.26.3", "@babel/preset-typescript": "^7.26.0", "@eslint/js": "^9.16.0", + "@patternfly/react-data-view": "^6.0.0", + "@patternfly/react-user-feedback": "^6.0.0", "@semantic-release/git": "^10.0.1", "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.1.0", @@ -110,9 +113,7 @@ "ts-jest": "^29.2.5", "ts-node": "^10.9.2", "typescript-eslint": "^8.15.0", - "wrangler": "^4.20.0", - "@patternfly/react-user-feedback": "^6.0.0", - "@patternfly/react-data-view": "^6.0.0" + "wrangler": "^4.20.0" }, "config": { "commitizen": { diff --git a/src/pages/api/icons/[iconName].ts b/src/pages/api/icons/[iconName].ts new file mode 100644 index 0000000..297f36a --- /dev/null +++ b/src/pages/api/icons/[iconName].ts @@ -0,0 +1,47 @@ +import type { APIRoute } from 'astro' +import { + createJsonResponse, + createSvgResponse, +} from '../../../utils/apiHelpers' +import { getIconSvg, parseIconId } from '../../../utils/icons/reactIcons' + +export const prerender = false + +/** + * GET /api/icons/[icon-name] + * Returns actual SVG markup for the icon. + * Icon name format: {set}_{iconName} (e.g., fa_FaCircle, md_MdHome) + */ +export const GET: APIRoute = async ({ params }) => { + const iconId = params.iconName + + if (!iconId) { + return createJsonResponse( + { error: 'Icon name parameter is required' }, + 400, + ) + } + + const parsed = parseIconId(iconId) + if (!parsed) { + return createJsonResponse( + { + error: 'Invalid icon name format', + expected: 'Use format {set}_{iconName} (e.g., fa_FaCircle, md_MdHome)', + }, + 400, + ) + } + + const { setId, iconName } = parsed + const svg = await getIconSvg(setId, iconName) + + if (!svg) { + return createJsonResponse( + { error: `Icon '${iconName}' not found in set '${setId}'` }, + 404, + ) + } + + return createSvgResponse(svg) +} diff --git a/src/pages/api/icons/index.ts b/src/pages/api/icons/index.ts new file mode 100644 index 0000000..da2bfc2 --- /dev/null +++ b/src/pages/api/icons/index.ts @@ -0,0 +1,32 @@ +import type { APIRoute } from 'astro' +import { createJsonResponse } from '../../../utils/apiHelpers' +import { getAllIcons, filterIcons } from '../../../utils/icons/reactIcons' + +export const prerender = false + +/** + * GET /api/icons + * Returns list of all available icons with metadata. + * + * GET /api/icons?filter=circle + * Returns filtered list of icons matching the filter term (case-insensitive). + */ +export const GET: APIRoute = async ({ url }) => { + try { + const filter = url.searchParams.get('filter') ?? '' + const icons = await getAllIcons() + const filtered = filterIcons(icons, filter) + + return createJsonResponse({ + icons: filtered, + total: filtered.length, + filter: filter || undefined, + }) + } catch (error) { + const details = error instanceof Error ? error.message : String(error) + return createJsonResponse( + { error: 'Failed to load icons', details }, + 500, + ) + } +} diff --git a/src/pages/api/index.ts b/src/pages/api/index.ts index ae1abeb..c39762d 100644 --- a/src/pages/api/index.ts +++ b/src/pages/api/index.ts @@ -367,6 +367,58 @@ export const GET: APIRoute = async () => ], }, }, + { + path: '/api/icons', + method: 'GET', + description: 'List all available icons with metadata from react-icons', + parameters: [ + { + name: 'filter', + in: 'query', + required: false, + type: 'string', + description: 'Filter icons by name (case-insensitive)', + example: 'circle', + }, + ], + returns: { + type: 'object', + description: 'List of icons with name, reactName, style, usage, unicode', + example: { + icons: [ + { + name: 'circle', + reactName: 'FaCircle', + style: 'solid', + usage: "import { FaCircle } from 'react-icons/fa'", + unicode: '', + }, + ], + total: 1, + filter: 'circle', + }, + }, + }, + { + path: '/api/icons/{icon-name}', + method: 'GET', + description: 'Get SVG markup for a specific icon', + parameters: [ + { + name: 'icon-name', + in: 'path', + required: true, + type: 'string', + description: 'Icon identifier in format {set}_{iconName} (e.g., fa_FaCircle, md_MdHome)', + example: 'fa_FaCircle', + }, + ], + returns: { + type: 'string', + contentType: 'image/svg+xml', + description: 'SVG markup for the icon', + }, + }, { path: '/api/{version}/{section}/{page}/{tab}/examples/{example}', method: 'GET', diff --git a/src/pages/api/openapi.json.ts b/src/pages/api/openapi.json.ts index c3f02a7..fd2625b 100644 --- a/src/pages/api/openapi.json.ts +++ b/src/pages/api/openapi.json.ts @@ -104,6 +104,87 @@ export const GET: APIRoute = async ({ url }) => { }, }, }, + '/icons': { + get: { + summary: 'List available icons', + description: + 'Returns list of all available icons from react-icons with metadata. Use filter query param to filter by name.', + operationId: 'getIcons', + parameters: [ + { + name: 'filter', + in: 'query', + required: false, + schema: { type: 'string' }, + description: 'Filter icons by name (case-insensitive)', + example: 'circle', + }, + ], + responses: { + '200': { + description: 'List of icons with metadata', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + icons: { + type: 'array', + items: { + type: 'object', + properties: { + name: { type: 'string', example: 'circle' }, + reactName: { type: 'string', example: 'FaCircle' }, + style: { type: 'string', example: 'solid' }, + usage: { + type: 'string', + example: "import { FaCircle } from 'react-icons/fa'", + }, + unicode: { type: 'string', example: '' }, + }, + }, + }, + total: { type: 'integer' }, + filter: { type: 'string' }, + }, + }, + }, + }, + }, + }, + }, + }, + '/icons/{icon-name}': { + get: { + summary: 'Get icon SVG markup', + description: + 'Returns actual SVG markup for the icon. Icon name format: {set}_{iconName} (e.g., fa_FaCircle, md_MdHome)', + operationId: 'getIconSvg', + parameters: [ + { + name: 'icon-name', + in: 'path', + required: true, + schema: { type: 'string' }, + description: 'Icon identifier: {set}_{iconName}', + example: 'fa_FaCircle', + }, + ], + responses: { + '200': { + description: 'SVG markup for the icon', + content: { + 'image/svg+xml': { + schema: { type: 'string' }, + }, + }, + }, + '404': { + description: 'Icon not found', + }, + }, + }, + }, '/openapi.json': { get: { summary: 'Get OpenAPI specification', diff --git a/src/utils/apiHelpers.ts b/src/utils/apiHelpers.ts index 8fa9c07..bb4cf4d 100644 --- a/src/utils/apiHelpers.ts +++ b/src/utils/apiHelpers.ts @@ -1,5 +1,5 @@ function getHeaders( - type: 'application/json' | 'text/plain', + type: 'application/json' | 'text/plain' | 'image/svg+xml', contentLength?: number, ): HeadersInit { const headers: HeadersInit = { @@ -36,6 +36,16 @@ export function createTextResponse( }) } +export function createSvgResponse( + content: string, + status: number = 200, +): Response { + return new Response(content, { + status, + headers: getHeaders('image/svg+xml', content.length), + }) +} + /** * Creates an index key by joining parts with '::' separator * Used to construct keys for looking up sections, pages, and tabs in the API index diff --git a/src/utils/icons/reactIcons.ts b/src/utils/icons/reactIcons.ts new file mode 100644 index 0000000..60c2946 --- /dev/null +++ b/src/utils/icons/reactIcons.ts @@ -0,0 +1,144 @@ +/** + * Utilities for working with react-icons from the ESM package. + * Icons are loaded from node_modules/react-icons icon set folders. + */ +import { renderToStaticMarkup } from 'react-dom/server' +import React from 'react' +import { IconsManifest } from 'react-icons/lib' + +export interface IconMetadata { + name: string + reactName: string + style: string + usage: string + unicode: string + /** Set id for SVG URL: /api/icons/{set}_{reactName} */ + set?: string +} + +const ICON_SET_IDS = IconsManifest.map((m) => m.id) + +/** Derive style from set id and react name (e.g., fa + FaRegCircle -> "regular") */ +function getStyle(setId: string, reactName: string): string { + if (setId === 'fa' || setId === 'fa6') { + if (reactName.startsWith('FaReg')) return 'regular' + if (reactName.startsWith('FaBrands')) return 'brands' + return 'solid' + } + return setId +} + +/** Convert PascalCase to kebab-case */ +function toKebabCase(str: string): string { + return str + .replace(/([a-z])([A-Z])/g, '$1-$2') + .replace(/([A-Z])([A-Z][a-z])/g, '$1-$2') + .toLowerCase() +} + +/** Derive base name from react name by removing set-specific prefixes */ +function getBaseName(setId: string, reactName: string): string { + let base = reactName + if (setId === 'fa' || setId === 'fa6') { + base = base.replace(/^Fa(Reg|Brands)?/, '') + } else if (setId === 'io' || setId === 'io5') { + base = base.replace(/^Io(5)?/, '') + } else if (setId === 'md') { + base = base.replace(/^Md/, '') + } else if (setId === 'hi' || setId === 'hi2') { + base = base.replace(/^Hi(2)?/, '') + } else { + const setPrefix = setId.charAt(0).toUpperCase() + setId.slice(1) + const prefix = new RegExp(`^${setPrefix}`, 'i') + base = base.replace(prefix, '') + } + return toKebabCase(base) || toKebabCase(reactName) +} + +/** + * Get all icons from all sets with metadata. + * Shape: { name, reactName, style, usage, unicode } + */ +export async function getAllIcons(): Promise { + const icons: IconMetadata[] = [] + + for (const setId of ICON_SET_IDS) { + try { + const module = await import(`react-icons/${setId}`) + const iconNames = Object.keys(module).filter( + (k) => typeof module[k] === 'function' && k !== 'default', + ) + + for (const reactName of iconNames) { + icons.push({ + name: getBaseName(setId, reactName), + reactName, + style: getStyle(setId, reactName), + usage: `import { ${reactName} } from 'react-icons/${setId}'`, + unicode: '', + set: setId, + }) + } + } catch { + // Skip sets that fail to load + } + } + + return icons +} + +/** + * Filter icons by search term (case-insensitive match on name or reactName) + */ +export function filterIcons( + icons: IconMetadata[], + filter: string, +): IconMetadata[] { + if (!filter || !filter.trim()) return icons + const term = filter.toLowerCase().trim() + return icons.filter( + (icon) => + icon.name.toLowerCase().includes(term) || + icon.reactName.toLowerCase().includes(term), + ) +} + +/** + * Get SVG markup for a specific icon. + * @param setId - Icon set id (e.g., "fa", "md") + * @param iconName - Icon component name (e.g., "FaCircle") + */ +export async function getIconSvg( + setId: string, + iconName: string, +): Promise { + if (!ICON_SET_IDS.includes(setId)) return null + + try { + const module = await import(`react-icons/${setId}`) + const IconComponent = module[iconName] + if (typeof IconComponent !== 'function') return null + + const element = React.createElement(IconComponent, { + size: '1em', + style: { verticalAlign: 'middle' }, + }) + return renderToStaticMarkup(element) + } catch { + return null + } +} + +/** + * Parse icon id "set_iconName" into { setId, iconName } + */ +export function parseIconId(iconId: string): { setId: string; iconName: string } | null { + const underscoreIndex = iconId.indexOf('_') + if (underscoreIndex <= 0) return null + + const setId = iconId.slice(0, underscoreIndex) + const iconName = iconId.slice(underscoreIndex + 1) + if (!setId || !iconName) return null + + return { setId, iconName } +} From a0d38552414f8a0ee09b3534313c6e7478ceb13e Mon Sep 17 00:00:00 2001 From: Donald Labaj Date: Wed, 4 Feb 2026 16:44:49 -0500 Subject: [PATCH 02/13] feat: Added icons end points. --- .../[version]/icons/[iconName].test.ts | 202 +++++++++++++++++ .../__tests__/[version]/icons/index.test.ts | 212 ++++++++++++++++++ .../api/{ => [version]}/icons/[iconName].ts | 27 ++- src/pages/api/{ => [version]}/icons/index.ts | 25 ++- src/pages/api/index.ts | 18 +- src/pages/api/openapi.json.ts | 18 +- 6 files changed, 488 insertions(+), 14 deletions(-) create mode 100644 src/__tests__/pages/api/__tests__/[version]/icons/[iconName].test.ts create mode 100644 src/__tests__/pages/api/__tests__/[version]/icons/index.test.ts rename src/pages/api/{ => [version]}/icons/[iconName].ts (54%) rename src/pages/api/{ => [version]}/icons/index.ts (50%) diff --git a/src/__tests__/pages/api/__tests__/[version]/icons/[iconName].test.ts b/src/__tests__/pages/api/__tests__/[version]/icons/[iconName].test.ts new file mode 100644 index 0000000..de123d9 --- /dev/null +++ b/src/__tests__/pages/api/__tests__/[version]/icons/[iconName].test.ts @@ -0,0 +1,202 @@ +import { GET } from '../../../../../../pages/api/[version]/icons/[iconName]' + +const mockApiIndex = { + versions: ['v5', 'v6'], + sections: {}, + pages: {}, + tabs: {}, +} + +const mockSvg = '' + +jest.mock('../../../../../../utils/icons/reactIcons', () => ({ + getIconSvg: jest.fn((setId: string, iconName: string) => { + if (setId === 'fa' && iconName === 'FaCircle') return Promise.resolve(mockSvg) + return Promise.resolve(null) + }), + parseIconId: jest.fn((iconId: string) => { + const underscoreIndex = iconId.indexOf('_') + if (underscoreIndex <= 0) return null + const setId = iconId.slice(0, underscoreIndex) + const iconName = iconId.slice(underscoreIndex + 1) + if (!setId || !iconName) return null + return { setId, iconName } + }), +})) + +it('returns SVG markup for valid icon', async () => { + global.fetch = jest.fn(() => + Promise.resolve({ + ok: true, + json: () => Promise.resolve(mockApiIndex), + } as Response), + ) + + const response = await GET({ + params: { version: 'v6', iconName: 'fa_FaCircle' }, + url: new URL('http://localhost:4321/api/v6/icons/fa_FaCircle'), + } as any) + const body = await response.text() + + expect(response.status).toBe(200) + expect(response.headers.get('Content-Type')).toBe( + 'image/svg+xml; charset=utf-8', + ) + expect(body).toBe(mockSvg) + expect(body).toContain(' { + global.fetch = jest.fn(() => + Promise.resolve({ + ok: true, + json: () => Promise.resolve(mockApiIndex), + } as Response), + ) + + const response = await GET({ + params: { version: 'v6', iconName: 'fa_FaNonExistent' }, + url: new URL('http://localhost:4321/api/v6/icons/fa_FaNonExistent'), + } as any) + const body = await response.json() + + expect(response.status).toBe(404) + expect(body).toHaveProperty('error') + expect(body.error).toContain('FaNonExistent') + expect(body.error).toContain('fa') + expect(body.error).toContain('not found') + + jest.restoreAllMocks() +}) + +it('returns 400 for invalid icon name format (no underscore)', async () => { + global.fetch = jest.fn(() => + Promise.resolve({ + ok: true, + json: () => Promise.resolve(mockApiIndex), + } as Response), + ) + + const response = await GET({ + params: { version: 'v6', iconName: 'invalid' }, + url: new URL('http://localhost:4321/api/v6/icons/invalid'), + } as any) + const body = await response.json() + + expect(response.status).toBe(400) + expect(body).toHaveProperty('error') + expect(body.error).toBe('Invalid icon name format') + expect(body).toHaveProperty('expected') + expect(body.expected).toContain('fa_FaCircle') + + jest.restoreAllMocks() +}) + +it('returns 400 for invalid icon name format (leading underscore)', async () => { + global.fetch = jest.fn(() => + Promise.resolve({ + ok: true, + json: () => Promise.resolve(mockApiIndex), + } as Response), + ) + + const response = await GET({ + params: { version: 'v6', iconName: '_FaCircle' }, + url: new URL('http://localhost:4321/api/v6/icons/_FaCircle'), + } as any) + const body = await response.json() + + expect(response.status).toBe(400) + expect(body).toHaveProperty('error') + expect(body.error).toBe('Invalid icon name format') + + jest.restoreAllMocks() +}) + +it('returns 400 when icon name parameter is missing', 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/icons'), + } as any) + const body = await response.json() + + expect(response.status).toBe(400) + expect(body).toHaveProperty('error') + expect(body.error).toContain('Icon name parameter is required') + + jest.restoreAllMocks() +}) + +it('returns 404 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', iconName: 'fa_FaCircle' }, + url: new URL('http://localhost:4321/api/v99/icons/fa_FaCircle'), + } 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 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: { iconName: 'fa_FaCircle' }, + url: new URL('http://localhost:4321/api/icons/fa_FaCircle'), + } 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('returns 500 when fetchApiIndex fails', async () => { + global.fetch = jest.fn(() => + Promise.resolve({ + ok: false, + status: 500, + statusText: 'Internal Server Error', + } as Response), + ) + + const response = await GET({ + params: { version: 'v6', iconName: 'fa_FaCircle' }, + url: new URL('http://localhost:4321/api/v6/icons/fa_FaCircle'), + } as any) + const body = await response.json() + + expect(response.status).toBe(500) + expect(body).toHaveProperty('error') + expect(body.error).toBe('Failed to fetch API index') + + jest.restoreAllMocks() +}) diff --git a/src/__tests__/pages/api/__tests__/[version]/icons/index.test.ts b/src/__tests__/pages/api/__tests__/[version]/icons/index.test.ts new file mode 100644 index 0000000..613dc29 --- /dev/null +++ b/src/__tests__/pages/api/__tests__/[version]/icons/index.test.ts @@ -0,0 +1,212 @@ +import { GET } from '../../../../../../pages/api/[version]/icons/index' + +const mockApiIndex = { + versions: ['v5', 'v6'], + sections: {}, + pages: {}, + tabs: {}, +} + +const mockIcons = [ + { + name: 'circle', + reactName: 'FaCircle', + style: 'solid', + usage: "import { FaCircle } from 'react-icons/fa'", + unicode: '', + set: 'fa', + }, + { + name: 'home', + reactName: 'MdHome', + style: 'md', + usage: "import { MdHome } from 'react-icons/md'", + unicode: '', + set: 'md', + }, + { + name: 'circle-outline', + reactName: 'FaRegCircle', + style: 'regular', + usage: "import { FaRegCircle } from 'react-icons/fa'", + unicode: '', + set: 'fa', + }, +] + +jest.mock('../../../../../../utils/icons/reactIcons', () => ({ + getAllIcons: jest.fn(() => Promise.resolve(mockIcons)), + filterIcons: jest.fn((icons: typeof mockIcons, filter: string) => { + if (!filter || !filter.trim()) return icons + const term = filter.toLowerCase().trim() + return icons.filter( + (icon: (typeof mockIcons)[0]) => + icon.name.toLowerCase().includes(term) || + icon.reactName.toLowerCase().includes(term), + ) + }), +})) + +it('returns all icons with metadata', 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/icons'), + } 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(body).toHaveProperty('icons') + expect(body).toHaveProperty('total') + expect(Array.isArray(body.icons)).toBe(true) + expect(body.icons).toHaveLength(3) + expect(body.total).toBe(3) + expect(body.icons[0]).toHaveProperty('name') + expect(body.icons[0]).toHaveProperty('reactName') + expect(body.icons[0]).toHaveProperty('style') + expect(body.icons[0]).toHaveProperty('usage') + expect(body.icons[0]).toHaveProperty('unicode') + + jest.restoreAllMocks() +}) + +it('filters icons 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/icons?filter=circle'), + } as any) + const body = await response.json() + + expect(response.status).toBe(200) + expect(body.icons).toHaveLength(2) + expect(body.total).toBe(2) + expect(body.filter).toBe('circle') + expect(body.icons.every((i: { name: string }) => i.name.includes('circle'))) + + 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/icons?filter=CIRCLE'), + } as any) + const body = await response.json() + + expect(response.status).toBe(200) + expect(body.icons.length).toBeGreaterThan(0) + + jest.restoreAllMocks() +}) + +it('returns empty icons 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' }, + url: new URL('http://localhost:4321/api/v6/icons?filter=nonexistent'), + } as any) + const body = await response.json() + + expect(response.status).toBe(200) + expect(body.icons).toHaveLength(0) + expect(body.total).toBe(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/icons'), + } 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/icons'), + } 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('returns 500 error when getAllIcons throws', async () => { + const { getAllIcons } = require('../../../../../../utils/icons/reactIcons') + ;(getAllIcons as jest.Mock).mockRejectedValueOnce(new Error('Load failed')) + + 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/icons'), + } as any) + const body = await response.json() + + expect(response.status).toBe(500) + expect(body).toHaveProperty('error') + expect(body.error).toBe('Failed to load icons') + expect(body).toHaveProperty('details') + + jest.restoreAllMocks() +}) diff --git a/src/pages/api/icons/[iconName].ts b/src/pages/api/[version]/icons/[iconName].ts similarity index 54% rename from src/pages/api/icons/[iconName].ts rename to src/pages/api/[version]/icons/[iconName].ts index 297f36a..b510ac3 100644 --- a/src/pages/api/icons/[iconName].ts +++ b/src/pages/api/[version]/icons/[iconName].ts @@ -2,18 +2,35 @@ import type { APIRoute } from 'astro' import { createJsonResponse, createSvgResponse, -} from '../../../utils/apiHelpers' -import { getIconSvg, parseIconId } from '../../../utils/icons/reactIcons' +} from '../../../../utils/apiHelpers' +import { fetchApiIndex } from '../../../../utils/apiIndex/fetch' +import { getIconSvg, parseIconId } from '../../../../utils/icons/reactIcons' export const prerender = false /** - * GET /api/icons/[icon-name] + * GET /api/{version}/icons/[icon-name] * Returns actual SVG markup for the icon. * Icon name format: {set}_{iconName} (e.g., fa_FaCircle, md_MdHome) */ -export const GET: APIRoute = async ({ params }) => { - const iconId = params.iconName +export const GET: APIRoute = async ({ params, url }) => { + const { version, iconName: iconId } = 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) + } + } catch { + return createJsonResponse({ error: 'Failed to fetch API index' }, 500) + } if (!iconId) { return createJsonResponse( diff --git a/src/pages/api/icons/index.ts b/src/pages/api/[version]/icons/index.ts similarity index 50% rename from src/pages/api/icons/index.ts rename to src/pages/api/[version]/icons/index.ts index da2bfc2..5a05d4c 100644 --- a/src/pages/api/icons/index.ts +++ b/src/pages/api/[version]/icons/index.ts @@ -1,18 +1,33 @@ import type { APIRoute } from 'astro' -import { createJsonResponse } from '../../../utils/apiHelpers' -import { getAllIcons, filterIcons } from '../../../utils/icons/reactIcons' +import { createJsonResponse } from '../../../../utils/apiHelpers' +import { fetchApiIndex } from '../../../../utils/apiIndex/fetch' +import { getAllIcons, filterIcons } from '../../../../utils/icons/reactIcons' export const prerender = false /** - * GET /api/icons + * GET /api/{version}/icons * Returns list of all available icons with metadata. * - * GET /api/icons?filter=circle + * GET /api/{version}/icons?filter=circle * Returns filtered list of icons matching the filter term (case-insensitive). */ -export const GET: APIRoute = async ({ url }) => { +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 filter = url.searchParams.get('filter') ?? '' const icons = await getAllIcons() const filtered = filterIcons(icons, filter) diff --git a/src/pages/api/index.ts b/src/pages/api/index.ts index c39762d..534022e 100644 --- a/src/pages/api/index.ts +++ b/src/pages/api/index.ts @@ -368,10 +368,17 @@ export const GET: APIRoute = async () => }, }, { - path: '/api/icons', + path: '/api/{version}/icons', method: 'GET', description: 'List all available icons with metadata from react-icons', parameters: [ + { + name: 'version', + in: 'path', + required: true, + type: 'string', + example: 'v6', + }, { name: 'filter', in: 'query', @@ -400,10 +407,17 @@ export const GET: APIRoute = async () => }, }, { - path: '/api/icons/{icon-name}', + path: '/api/{version}/icons/{icon-name}', method: 'GET', description: 'Get SVG markup for a specific icon', parameters: [ + { + name: 'version', + in: 'path', + required: true, + type: 'string', + example: 'v6', + }, { name: 'icon-name', in: 'path', diff --git a/src/pages/api/openapi.json.ts b/src/pages/api/openapi.json.ts index fd2625b..081bc52 100644 --- a/src/pages/api/openapi.json.ts +++ b/src/pages/api/openapi.json.ts @@ -104,13 +104,20 @@ export const GET: APIRoute = async ({ url }) => { }, }, }, - '/icons': { + '/{version}/icons': { get: { summary: 'List available icons', description: 'Returns list of all available icons from react-icons with metadata. Use filter query param to filter by name.', operationId: 'getIcons', parameters: [ + { + name: 'version', + in: 'path', + required: true, + schema: { type: 'string' }, + example: 'v6', + }, { name: 'filter', in: 'query', @@ -154,13 +161,20 @@ export const GET: APIRoute = async ({ url }) => { }, }, }, - '/icons/{icon-name}': { + '/{version}/icons/{icon-name}': { get: { summary: 'Get icon SVG markup', description: 'Returns actual SVG markup for the icon. Icon name format: {set}_{iconName} (e.g., fa_FaCircle, md_MdHome)', operationId: 'getIconSvg', parameters: [ + { + name: 'version', + in: 'path', + required: true, + schema: { type: 'string' }, + example: 'v6', + }, { name: 'icon-name', in: 'path', From da36b8d89b3f1c20f68beb1f254ea7f7d6738f86 Mon Sep 17 00:00:00 2001 From: Donald Labaj Date: Fri, 6 Feb 2026 13:14:24 -0500 Subject: [PATCH 03/13] chore: Updated with a few modifications. --- .../pages/api/__tests__/[version]/icons/index.test.ts | 2 +- src/pages/api/[version]/icons/[iconName].ts | 5 +---- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/src/__tests__/pages/api/__tests__/[version]/icons/index.test.ts b/src/__tests__/pages/api/__tests__/[version]/icons/index.test.ts index 613dc29..ce8aae2 100644 --- a/src/__tests__/pages/api/__tests__/[version]/icons/index.test.ts +++ b/src/__tests__/pages/api/__tests__/[version]/icons/index.test.ts @@ -97,7 +97,7 @@ it('filters icons when filter parameter is provided', async () => { expect(body.icons).toHaveLength(2) expect(body.total).toBe(2) expect(body.filter).toBe('circle') - expect(body.icons.every((i: { name: string }) => i.name.includes('circle'))) + expect(body.icons.every((i: { name: string }) => i.name.includes('circle'))).toBe(true) jest.restoreAllMocks() }) diff --git a/src/pages/api/[version]/icons/[iconName].ts b/src/pages/api/[version]/icons/[iconName].ts index b510ac3..4bb0d42 100644 --- a/src/pages/api/[version]/icons/[iconName].ts +++ b/src/pages/api/[version]/icons/[iconName].ts @@ -54,10 +54,7 @@ export const GET: APIRoute = async ({ params, url }) => { const svg = await getIconSvg(setId, iconName) if (!svg) { - return createJsonResponse( - { error: `Icon '${iconName}' not found in set '${setId}'` }, - 404, - ) + return createJsonResponse([]); } return createSvgResponse(svg) From 24375727131d08890647fbc540488044e44b7991 Mon Sep 17 00:00:00 2001 From: Donald Labaj Date: Fri, 6 Feb 2026 13:28:05 -0500 Subject: [PATCH 04/13] fixed broken test. --- src/pages/api/[version]/icons/[iconName].ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/pages/api/[version]/icons/[iconName].ts b/src/pages/api/[version]/icons/[iconName].ts index 4bb0d42..b510ac3 100644 --- a/src/pages/api/[version]/icons/[iconName].ts +++ b/src/pages/api/[version]/icons/[iconName].ts @@ -54,7 +54,10 @@ export const GET: APIRoute = async ({ params, url }) => { const svg = await getIconSvg(setId, iconName) if (!svg) { - return createJsonResponse([]); + return createJsonResponse( + { error: `Icon '${iconName}' not found in set '${setId}'` }, + 404, + ) } return createSvgResponse(svg) From bc103e11ddf21c607fff012a6efe8cd7a9430f5b Mon Sep 17 00:00:00 2001 From: Donald Labaj Date: Fri, 6 Feb 2026 13:57:36 -0500 Subject: [PATCH 05/13] chore: fix lint errors. --- .../[version]/icons/[iconName].test.ts | 12 ++++++-- .../__tests__/[version]/icons/index.test.ts | 6 ++-- src/utils/icons/reactIcons.ts | 28 ++++++++++++++----- 3 files changed, 34 insertions(+), 12 deletions(-) diff --git a/src/__tests__/pages/api/__tests__/[version]/icons/[iconName].test.ts b/src/__tests__/pages/api/__tests__/[version]/icons/[iconName].test.ts index de123d9..2f5f714 100644 --- a/src/__tests__/pages/api/__tests__/[version]/icons/[iconName].test.ts +++ b/src/__tests__/pages/api/__tests__/[version]/icons/[iconName].test.ts @@ -11,15 +11,21 @@ const mockSvg = '< jest.mock('../../../../../../utils/icons/reactIcons', () => ({ getIconSvg: jest.fn((setId: string, iconName: string) => { - if (setId === 'fa' && iconName === 'FaCircle') return Promise.resolve(mockSvg) + if (setId === 'fa' && iconName === 'FaCircle') { + return Promise.resolve(mockSvg) + } return Promise.resolve(null) }), parseIconId: jest.fn((iconId: string) => { const underscoreIndex = iconId.indexOf('_') - if (underscoreIndex <= 0) return null + if (underscoreIndex <= 0) { + return null + } const setId = iconId.slice(0, underscoreIndex) const iconName = iconId.slice(underscoreIndex + 1) - if (!setId || !iconName) return null + if (!setId || !iconName) { + return null + } return { setId, iconName } }), })) diff --git a/src/__tests__/pages/api/__tests__/[version]/icons/index.test.ts b/src/__tests__/pages/api/__tests__/[version]/icons/index.test.ts index ce8aae2..81a94bc 100644 --- a/src/__tests__/pages/api/__tests__/[version]/icons/index.test.ts +++ b/src/__tests__/pages/api/__tests__/[version]/icons/index.test.ts @@ -1,4 +1,5 @@ import { GET } from '../../../../../../pages/api/[version]/icons/index' +import { getAllIcons } from '../../../../../../utils/icons/reactIcons' const mockApiIndex = { versions: ['v5', 'v6'], @@ -37,7 +38,9 @@ const mockIcons = [ jest.mock('../../../../../../utils/icons/reactIcons', () => ({ getAllIcons: jest.fn(() => Promise.resolve(mockIcons)), filterIcons: jest.fn((icons: typeof mockIcons, filter: string) => { - if (!filter || !filter.trim()) return icons + if (!filter || !filter.trim()) { + return icons + } const term = filter.toLowerCase().trim() return icons.filter( (icon: (typeof mockIcons)[0]) => @@ -187,7 +190,6 @@ it('returns 400 error when version parameter is missing', async () => { }) it('returns 500 error when getAllIcons throws', async () => { - const { getAllIcons } = require('../../../../../../utils/icons/reactIcons') ;(getAllIcons as jest.Mock).mockRejectedValueOnce(new Error('Load failed')) global.fetch = jest.fn(() => diff --git a/src/utils/icons/reactIcons.ts b/src/utils/icons/reactIcons.ts index 60c2946..99cf35b 100644 --- a/src/utils/icons/reactIcons.ts +++ b/src/utils/icons/reactIcons.ts @@ -21,8 +21,12 @@ const ICON_SET_IDS = IconsManifest.map((m) => m.id) /** Derive style from set id and react name (e.g., fa + FaRegCircle -> "regular") */ function getStyle(setId: string, reactName: string): string { if (setId === 'fa' || setId === 'fa6') { - if (reactName.startsWith('FaReg')) return 'regular' - if (reactName.startsWith('FaBrands')) return 'brands' + if (reactName.startsWith('FaReg')) { + return 'regular' + } + if (reactName.startsWith('FaBrands')) { + return 'brands' + } return 'solid' } return setId @@ -94,7 +98,9 @@ export function filterIcons( icons: IconMetadata[], filter: string, ): IconMetadata[] { - if (!filter || !filter.trim()) return icons + if (!filter || !filter.trim()) { + return icons + } const term = filter.toLowerCase().trim() return icons.filter( (icon) => @@ -112,12 +118,16 @@ export async function getIconSvg( setId: string, iconName: string, ): Promise { - if (!ICON_SET_IDS.includes(setId)) return null + if (!ICON_SET_IDS.includes(setId)) { + return null + } try { const module = await import(`react-icons/${setId}`) const IconComponent = module[iconName] - if (typeof IconComponent !== 'function') return null + if (typeof IconComponent !== 'function') { + return null + } const element = React.createElement(IconComponent, { size: '1em', @@ -134,11 +144,15 @@ export async function getIconSvg( */ export function parseIconId(iconId: string): { setId: string; iconName: string } | null { const underscoreIndex = iconId.indexOf('_') - if (underscoreIndex <= 0) return null + if (underscoreIndex <= 0) { + return null + } const setId = iconId.slice(0, underscoreIndex) const iconName = iconId.slice(underscoreIndex + 1) - if (!setId || !iconName) return null + if (!setId || !iconName) { + return null + } return { setId, iconName } } From e38d0a112f0add536ede486df483ee08b89dc427 Mon Sep 17 00:00:00 2001 From: Donald Labaj Date: Fri, 6 Feb 2026 15:33:55 -0500 Subject: [PATCH 06/13] fix: Added prerender step for cloudflare. --- .../__tests__/[version]/icons/index.test.ts | 71 +++++++------------ src/pages/api/[version]/icons/index.ts | 5 +- src/pages/iconsIndex.json.ts | 31 ++++++++ src/utils/icons/fetch.ts | 27 +++++++ 4 files changed, 87 insertions(+), 47 deletions(-) create mode 100644 src/pages/iconsIndex.json.ts create mode 100644 src/utils/icons/fetch.ts diff --git a/src/__tests__/pages/api/__tests__/[version]/icons/index.test.ts b/src/__tests__/pages/api/__tests__/[version]/icons/index.test.ts index 81a94bc..f5b50d5 100644 --- a/src/__tests__/pages/api/__tests__/[version]/icons/index.test.ts +++ b/src/__tests__/pages/api/__tests__/[version]/icons/index.test.ts @@ -1,5 +1,4 @@ import { GET } from '../../../../../../pages/api/[version]/icons/index' -import { getAllIcons } from '../../../../../../utils/icons/reactIcons' const mockApiIndex = { versions: ['v5', 'v6'], @@ -35,8 +34,18 @@ const mockIcons = [ }, ] +function createFetchMock(): typeof fetch { + return jest.fn((input: RequestInfo | URL) => { + const url = typeof input === 'string' ? input : input.toString() + const json = () => + Promise.resolve( + url.includes('iconsIndex.json') ? { icons: mockIcons } : mockApiIndex + ) + return Promise.resolve({ ok: true, json } as Response) + }) as typeof fetch +} + jest.mock('../../../../../../utils/icons/reactIcons', () => ({ - getAllIcons: jest.fn(() => Promise.resolve(mockIcons)), filterIcons: jest.fn((icons: typeof mockIcons, filter: string) => { if (!filter || !filter.trim()) { return icons @@ -51,12 +60,7 @@ jest.mock('../../../../../../utils/icons/reactIcons', () => ({ })) it('returns all icons with metadata', async () => { - global.fetch = jest.fn(() => - Promise.resolve({ - ok: true, - json: () => Promise.resolve(mockApiIndex), - } as Response), - ) + global.fetch = createFetchMock() const response = await GET({ params: { version: 'v6' }, @@ -83,12 +87,7 @@ it('returns all icons with metadata', async () => { }) it('filters icons when filter parameter is provided', async () => { - global.fetch = jest.fn(() => - Promise.resolve({ - ok: true, - json: () => Promise.resolve(mockApiIndex), - } as Response), - ) + global.fetch = createFetchMock() const response = await GET({ params: { version: 'v6' }, @@ -106,12 +105,7 @@ it('filters icons when filter parameter is provided', async () => { }) it('filter is case-insensitive', async () => { - global.fetch = jest.fn(() => - Promise.resolve({ - ok: true, - json: () => Promise.resolve(mockApiIndex), - } as Response), - ) + global.fetch = createFetchMock() const response = await GET({ params: { version: 'v6' }, @@ -126,12 +120,7 @@ it('filter is case-insensitive', async () => { }) it('returns empty icons array when filter yields no matches', async () => { - global.fetch = jest.fn(() => - Promise.resolve({ - ok: true, - json: () => Promise.resolve(mockApiIndex), - } as Response), - ) + global.fetch = createFetchMock() const response = await GET({ params: { version: 'v6' }, @@ -147,12 +136,7 @@ it('returns empty icons array when filter yields no matches', async () => { }) it('returns 404 error for nonexistent version', async () => { - global.fetch = jest.fn(() => - Promise.resolve({ - ok: true, - json: () => Promise.resolve(mockApiIndex), - } as Response), - ) + global.fetch = createFetchMock() const response = await GET({ params: { version: 'v99' }, @@ -169,12 +153,7 @@ it('returns 404 error for nonexistent version', async () => { }) it('returns 400 error when version parameter is missing', async () => { - global.fetch = jest.fn(() => - Promise.resolve({ - ok: true, - json: () => Promise.resolve(mockApiIndex), - } as Response), - ) + global.fetch = createFetchMock() const response = await GET({ params: {}, @@ -189,15 +168,17 @@ it('returns 400 error when version parameter is missing', async () => { jest.restoreAllMocks() }) -it('returns 500 error when getAllIcons throws', async () => { - ;(getAllIcons as jest.Mock).mockRejectedValueOnce(new Error('Load failed')) - - global.fetch = jest.fn(() => - Promise.resolve({ +it('returns 500 error when fetchIconsIndex throws', async () => { + global.fetch = jest.fn((input: RequestInfo | URL) => { + const url = typeof input === 'string' ? input : input.toString() + if (url.includes('iconsIndex.json')) { + return Promise.resolve({ ok: false, status: 500, statusText: 'Internal Server Error' } as Response) + } + return Promise.resolve({ ok: true, json: () => Promise.resolve(mockApiIndex), - } as Response), - ) + } as Response) + }) as typeof fetch const response = await GET({ params: { version: 'v6' }, diff --git a/src/pages/api/[version]/icons/index.ts b/src/pages/api/[version]/icons/index.ts index 5a05d4c..93105f5 100644 --- a/src/pages/api/[version]/icons/index.ts +++ b/src/pages/api/[version]/icons/index.ts @@ -1,7 +1,8 @@ import type { APIRoute } from 'astro' import { createJsonResponse } from '../../../../utils/apiHelpers' import { fetchApiIndex } from '../../../../utils/apiIndex/fetch' -import { getAllIcons, filterIcons } from '../../../../utils/icons/reactIcons' +import { fetchIconsIndex } from '../../../../utils/icons/fetch' +import { filterIcons } from '../../../../utils/icons/reactIcons' export const prerender = false @@ -29,7 +30,7 @@ export const GET: APIRoute = async ({ params, url }) => { } const filter = url.searchParams.get('filter') ?? '' - const icons = await getAllIcons() + const icons = await fetchIconsIndex(url) const filtered = filterIcons(icons, filter) return createJsonResponse({ diff --git a/src/pages/iconsIndex.json.ts b/src/pages/iconsIndex.json.ts new file mode 100644 index 0000000..52e724f --- /dev/null +++ b/src/pages/iconsIndex.json.ts @@ -0,0 +1,31 @@ +import type { APIRoute } from 'astro' +import { getAllIcons } from '../utils/icons/reactIcons' + +/** + * Prerender at build time so this doesn't run in the Cloudflare Worker. + * getAllIcons() uses dynamic imports of react-icons which fail in Workers + * due to bundle size and Node.js compatibility. + */ +export const prerender = true + +export const GET: APIRoute = async () => { + try { + const icons = await getAllIcons() + return new Response(JSON.stringify({ icons }), { + status: 200, + headers: { + 'Content-Type': 'application/json', + }, + }) + } catch (error) { + return new Response( + JSON.stringify({ error: 'Failed to load icons index', details: String(error) }), + { + status: 500, + headers: { + 'Content-Type': 'application/json', + }, + } + ) + } +} diff --git a/src/utils/icons/fetch.ts b/src/utils/icons/fetch.ts new file mode 100644 index 0000000..ccb0b66 --- /dev/null +++ b/src/utils/icons/fetch.ts @@ -0,0 +1,27 @@ +import type { IconMetadata } from './reactIcons' + +export interface IconsIndex { + icons: IconMetadata[] +} + +/** + * Fetches the icons index from the server as a static asset. + * Used by API routes at runtime instead of calling getAllIcons() which uses + * dynamic imports that fail in Cloudflare Workers. + * + * @param url - The URL object from the API route context + * @returns Promise resolving to the icons index structure + */ +export async function fetchIconsIndex(url: URL): Promise { + const iconsIndexUrl = new URL('/iconsIndex.json', url.origin) + const response = await fetch(iconsIndexUrl) + + if (!response.ok) { + throw new Error( + `Failed to load icons index: ${response.status} ${response.statusText}` + ) + } + + const data = (await response.json()) as IconsIndex + return data.icons +} From 0d52a5a4d1af3f7918d4990d36cd89bba320e710 Mon Sep 17 00:00:00 2001 From: Donald Labaj Date: Fri, 6 Feb 2026 16:00:12 -0500 Subject: [PATCH 07/13] fix: Updated to prerender svgs as well for cloud flare. --- .../[version]/icons/[iconName].test.ts | 99 +++++++++---------- src/pages/api/[version]/icons/[iconName].ts | 6 +- src/pages/iconsSvgs/[setId].json.ts | 46 +++++++++ src/utils/icons/fetch.ts | 23 +++++ src/utils/icons/reactIcons.ts | 35 +++++++ 5 files changed, 152 insertions(+), 57 deletions(-) create mode 100644 src/pages/iconsSvgs/[setId].json.ts diff --git a/src/__tests__/pages/api/__tests__/[version]/icons/[iconName].test.ts b/src/__tests__/pages/api/__tests__/[version]/icons/[iconName].test.ts index 2f5f714..2b99aac 100644 --- a/src/__tests__/pages/api/__tests__/[version]/icons/[iconName].test.ts +++ b/src/__tests__/pages/api/__tests__/[version]/icons/[iconName].test.ts @@ -9,13 +9,30 @@ const mockApiIndex = { const mockSvg = '' -jest.mock('../../../../../../utils/icons/reactIcons', () => ({ - getIconSvg: jest.fn((setId: string, iconName: string) => { - if (setId === 'fa' && iconName === 'FaCircle') { - return Promise.resolve(mockSvg) +const mockIconSvgs: Record> = { + fa: { FaCircle: mockSvg }, +} + +function createFetchMock(): typeof fetch { + return jest.fn((input: RequestInfo | URL) => { + const url = typeof input === 'string' ? input : input.toString() + const match = url.match(/\/iconsSvgs\/([^/]+)\.json/) + if (match) { + const setId = match[1] + const svgs = mockIconSvgs[setId] ?? {} + return Promise.resolve({ + ok: true, + json: () => Promise.resolve(svgs), + } as Response) } - return Promise.resolve(null) - }), + return Promise.resolve({ + ok: true, + json: () => Promise.resolve(mockApiIndex), + } as Response) + }) as typeof fetch +} + +jest.mock('../../../../../../utils/icons/reactIcons', () => ({ parseIconId: jest.fn((iconId: string) => { const underscoreIndex = iconId.indexOf('_') if (underscoreIndex <= 0) { @@ -31,12 +48,7 @@ jest.mock('../../../../../../utils/icons/reactIcons', () => ({ })) it('returns SVG markup for valid icon', async () => { - global.fetch = jest.fn(() => - Promise.resolve({ - ok: true, - json: () => Promise.resolve(mockApiIndex), - } as Response), - ) + global.fetch = createFetchMock() const response = await GET({ params: { version: 'v6', iconName: 'fa_FaCircle' }, @@ -55,12 +67,7 @@ it('returns SVG markup for valid icon', async () => { }) it('returns 404 when icon is not found in set', async () => { - global.fetch = jest.fn(() => - Promise.resolve({ - ok: true, - json: () => Promise.resolve(mockApiIndex), - } as Response), - ) + global.fetch = createFetchMock() const response = await GET({ params: { version: 'v6', iconName: 'fa_FaNonExistent' }, @@ -78,12 +85,7 @@ it('returns 404 when icon is not found in set', async () => { }) it('returns 400 for invalid icon name format (no underscore)', async () => { - global.fetch = jest.fn(() => - Promise.resolve({ - ok: true, - json: () => Promise.resolve(mockApiIndex), - } as Response), - ) + global.fetch = createFetchMock() const response = await GET({ params: { version: 'v6', iconName: 'invalid' }, @@ -101,12 +103,7 @@ it('returns 400 for invalid icon name format (no underscore)', async () => { }) it('returns 400 for invalid icon name format (leading underscore)', async () => { - global.fetch = jest.fn(() => - Promise.resolve({ - ok: true, - json: () => Promise.resolve(mockApiIndex), - } as Response), - ) + global.fetch = createFetchMock() const response = await GET({ params: { version: 'v6', iconName: '_FaCircle' }, @@ -122,12 +119,7 @@ it('returns 400 for invalid icon name format (leading underscore)', async () => }) it('returns 400 when icon name parameter is missing', async () => { - global.fetch = jest.fn(() => - Promise.resolve({ - ok: true, - json: () => Promise.resolve(mockApiIndex), - } as Response), - ) + global.fetch = createFetchMock() const response = await GET({ params: { version: 'v6' }, @@ -143,12 +135,7 @@ it('returns 400 when icon name parameter is missing', async () => { }) it('returns 404 for nonexistent version', async () => { - global.fetch = jest.fn(() => - Promise.resolve({ - ok: true, - json: () => Promise.resolve(mockApiIndex), - } as Response), - ) + global.fetch = createFetchMock() const response = await GET({ params: { version: 'v99', iconName: 'fa_FaCircle' }, @@ -165,12 +152,7 @@ it('returns 404 for nonexistent version', async () => { }) it('returns 400 when version parameter is missing', async () => { - global.fetch = jest.fn(() => - Promise.resolve({ - ok: true, - json: () => Promise.resolve(mockApiIndex), - } as Response), - ) + global.fetch = createFetchMock() const response = await GET({ params: { iconName: 'fa_FaCircle' }, @@ -186,13 +168,20 @@ it('returns 400 when version parameter is missing', async () => { }) it('returns 500 when fetchApiIndex fails', async () => { - global.fetch = jest.fn(() => - Promise.resolve({ - ok: false, - status: 500, - statusText: 'Internal Server Error', - } as Response), - ) + global.fetch = jest.fn((input: RequestInfo | URL) => { + const url = typeof input === 'string' ? input : input.toString() + if (url.includes('apiIndex.json')) { + return Promise.resolve({ + ok: false, + status: 500, + statusText: 'Internal Server Error', + } as Response) + } + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({}), + } as Response) + }) as typeof fetch const response = await GET({ params: { version: 'v6', iconName: 'fa_FaCircle' }, diff --git a/src/pages/api/[version]/icons/[iconName].ts b/src/pages/api/[version]/icons/[iconName].ts index b510ac3..bde1882 100644 --- a/src/pages/api/[version]/icons/[iconName].ts +++ b/src/pages/api/[version]/icons/[iconName].ts @@ -4,7 +4,8 @@ import { createSvgResponse, } from '../../../../utils/apiHelpers' import { fetchApiIndex } from '../../../../utils/apiIndex/fetch' -import { getIconSvg, parseIconId } from '../../../../utils/icons/reactIcons' +import { fetchIconSvgs } from '../../../../utils/icons/fetch' +import { parseIconId } from '../../../../utils/icons/reactIcons' export const prerender = false @@ -51,7 +52,8 @@ export const GET: APIRoute = async ({ params, url }) => { } const { setId, iconName } = parsed - const svg = await getIconSvg(setId, iconName) + const svgs = await fetchIconSvgs(url, setId) + const svg = svgs?.[iconName] ?? null if (!svg) { return createJsonResponse( diff --git a/src/pages/iconsSvgs/[setId].json.ts b/src/pages/iconsSvgs/[setId].json.ts new file mode 100644 index 0000000..9c150cc --- /dev/null +++ b/src/pages/iconsSvgs/[setId].json.ts @@ -0,0 +1,46 @@ +import type { APIRoute, GetStaticPaths } from 'astro' +import { IconsManifest } from 'react-icons/lib' +import { getIconSvgsForSet } from '../../utils/icons/reactIcons' + +/** + * Prerender at build time so this doesn't run in the Cloudflare Worker. + * getIconSvgsForSet() uses dynamic imports of react-icons which fail in Workers. + */ +export const prerender = true + +export const getStaticPaths: GetStaticPaths = async () => + IconsManifest.map((m) => ({ + params: { setId: m.id }, + })) + +export const GET: APIRoute = async ({ params }) => { + const { setId } = params + + if (!setId) { + return new Response( + JSON.stringify({ error: 'Set ID is required' }), + { status: 400, headers: { 'Content-Type': 'application/json' } } + ) + } + + try { + const svgs = await getIconSvgsForSet(setId) + return new Response(JSON.stringify(svgs), { + status: 200, + headers: { + 'Content-Type': 'application/json', + }, + }) + } catch (error) { + return new Response( + JSON.stringify({ + error: 'Failed to load icon SVGs', + details: String(error), + }), + { + status: 500, + headers: { 'Content-Type': 'application/json' }, + } + ) + } +} diff --git a/src/utils/icons/fetch.ts b/src/utils/icons/fetch.ts index ccb0b66..5963772 100644 --- a/src/utils/icons/fetch.ts +++ b/src/utils/icons/fetch.ts @@ -25,3 +25,26 @@ export async function fetchIconsIndex(url: URL): Promise { const data = (await response.json()) as IconsIndex return data.icons } + +/** + * Fetches prerendered SVG markup for all icons in a set. + * Used by the icon SVG API route at runtime instead of getIconSvg() which + * uses dynamic imports that fail in Cloudflare Workers. + * + * @param url - The URL object from the API route context + * @param setId - Icon set id (e.g., "fa", "ci") + * @returns Promise resolving to Record of iconName -> SVG string, or null if fetch fails + */ +export async function fetchIconSvgs( + url: URL, + setId: string, +): Promise | null> { + const iconsSvgsUrl = new URL(`/iconsSvgs/${setId}.json`, url.origin) + const response = await fetch(iconsSvgsUrl) + + if (!response.ok) { + return null + } + + return (await response.json()) as Record +} diff --git a/src/utils/icons/reactIcons.ts b/src/utils/icons/reactIcons.ts index 99cf35b..575727c 100644 --- a/src/utils/icons/reactIcons.ts +++ b/src/utils/icons/reactIcons.ts @@ -109,6 +109,41 @@ export function filterIcons( ) } +/** + * Get SVG markup for all icons in a set. Used at build time for prerendering. + * @param setId - Icon set id (e.g., "fa", "md") + * @returns Record of iconName -> SVG string + */ +export async function getIconSvgsForSet( + setId: string, +): Promise> { + if (!ICON_SET_IDS.includes(setId)) { + return {} + } + + try { + const module = await import(`react-icons/${setId}`) + const svgs: Record = {} + + for (const iconName of Object.keys(module)) { + const IconComponent = module[iconName] + if (typeof IconComponent !== 'function' || iconName === 'default') { + continue + } + + const element = React.createElement(IconComponent, { + size: '1em', + style: { verticalAlign: 'middle' }, + }) + svgs[iconName] = renderToStaticMarkup(element) + } + + return svgs + } catch { + return {} + } +} + /** * Get SVG markup for a specific icon. * @param setId - Icon set id (e.g., "fa", "md") From a8654f0c0bcd2a7a0dd274d31c516b748263bb32 Mon Sep 17 00:00:00 2001 From: Donald Labaj Date: Wed, 11 Feb 2026 10:22:57 -0500 Subject: [PATCH 08/13] Updated to react name as the end point. --- .../[version]/icons/[iconName].test.ts | 75 +++++++------------ src/pages/api/[version]/icons/[iconName].ts | 28 +++---- src/pages/api/index.ts | 4 +- src/pages/api/openapi.json.ts | 6 +- src/utils/icons/reactIcons.ts | 2 +- 5 files changed, 45 insertions(+), 70 deletions(-) diff --git a/src/__tests__/pages/api/__tests__/[version]/icons/[iconName].test.ts b/src/__tests__/pages/api/__tests__/[version]/icons/[iconName].test.ts index 2b99aac..f912cab 100644 --- a/src/__tests__/pages/api/__tests__/[version]/icons/[iconName].test.ts +++ b/src/__tests__/pages/api/__tests__/[version]/icons/[iconName].test.ts @@ -13,9 +13,21 @@ const mockIconSvgs: Record> = { fa: { FaCircle: mockSvg }, } +const mockIconsIndex = { + icons: [ + { name: 'circle', reactName: 'FaCircle', style: 'solid', usage: '', unicode: '', set: 'fa' }, + ], +} + function createFetchMock(): typeof fetch { return jest.fn((input: RequestInfo | URL) => { const url = typeof input === 'string' ? input : input.toString() + if (url.includes('/iconsIndex.json')) { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve(mockIconsIndex), + } as Response) + } const match = url.match(/\/iconsSvgs\/([^/]+)\.json/) if (match) { const setId = match[1] @@ -32,27 +44,12 @@ function createFetchMock(): typeof fetch { }) as typeof fetch } -jest.mock('../../../../../../utils/icons/reactIcons', () => ({ - parseIconId: jest.fn((iconId: string) => { - const underscoreIndex = iconId.indexOf('_') - if (underscoreIndex <= 0) { - return null - } - const setId = iconId.slice(0, underscoreIndex) - const iconName = iconId.slice(underscoreIndex + 1) - if (!setId || !iconName) { - return null - } - return { setId, iconName } - }), -})) - it('returns SVG markup for valid icon', async () => { global.fetch = createFetchMock() const response = await GET({ - params: { version: 'v6', iconName: 'fa_FaCircle' }, - url: new URL('http://localhost:4321/api/v6/icons/fa_FaCircle'), + params: { version: 'v6', iconName: 'FaCircle' }, + url: new URL('http://localhost:4321/api/v6/icons/FaCircle'), } as any) const body = await response.text() @@ -66,25 +63,24 @@ it('returns SVG markup for valid icon', async () => { jest.restoreAllMocks() }) -it('returns 404 when icon is not found in set', async () => { +it('returns 404 when icon is not found', async () => { global.fetch = createFetchMock() const response = await GET({ - params: { version: 'v6', iconName: 'fa_FaNonExistent' }, - url: new URL('http://localhost:4321/api/v6/icons/fa_FaNonExistent'), + params: { version: 'v6', iconName: 'FaNonExistent' }, + url: new URL('http://localhost:4321/api/v6/icons/FaNonExistent'), } as any) const body = await response.json() expect(response.status).toBe(404) expect(body).toHaveProperty('error') expect(body.error).toContain('FaNonExistent') - expect(body.error).toContain('fa') expect(body.error).toContain('not found') jest.restoreAllMocks() }) -it('returns 400 for invalid icon name format (no underscore)', async () => { +it('returns 404 when icon name is not in index', async () => { global.fetch = createFetchMock() const response = await GET({ @@ -93,27 +89,10 @@ it('returns 400 for invalid icon name format (no underscore)', async () => { } as any) const body = await response.json() - expect(response.status).toBe(400) - expect(body).toHaveProperty('error') - expect(body.error).toBe('Invalid icon name format') - expect(body).toHaveProperty('expected') - expect(body.expected).toContain('fa_FaCircle') - - jest.restoreAllMocks() -}) - -it('returns 400 for invalid icon name format (leading underscore)', async () => { - global.fetch = createFetchMock() - - const response = await GET({ - params: { version: 'v6', iconName: '_FaCircle' }, - url: new URL('http://localhost:4321/api/v6/icons/_FaCircle'), - } as any) - const body = await response.json() - - expect(response.status).toBe(400) + expect(response.status).toBe(404) expect(body).toHaveProperty('error') - expect(body.error).toBe('Invalid icon name format') + expect(body.error).toContain('invalid') + expect(body.error).toContain('not found') jest.restoreAllMocks() }) @@ -138,8 +117,8 @@ it('returns 404 for nonexistent version', async () => { global.fetch = createFetchMock() const response = await GET({ - params: { version: 'v99', iconName: 'fa_FaCircle' }, - url: new URL('http://localhost:4321/api/v99/icons/fa_FaCircle'), + params: { version: 'v99', iconName: 'FaCircle' }, + url: new URL('http://localhost:4321/api/v99/icons/FaCircle'), } as any) const body = await response.json() @@ -155,8 +134,8 @@ it('returns 400 when version parameter is missing', async () => { global.fetch = createFetchMock() const response = await GET({ - params: { iconName: 'fa_FaCircle' }, - url: new URL('http://localhost:4321/api/icons/fa_FaCircle'), + params: { iconName: 'FaCircle' }, + url: new URL('http://localhost:4321/api/icons/FaCircle'), } as any) const body = await response.json() @@ -184,8 +163,8 @@ it('returns 500 when fetchApiIndex fails', async () => { }) as typeof fetch const response = await GET({ - params: { version: 'v6', iconName: 'fa_FaCircle' }, - url: new URL('http://localhost:4321/api/v6/icons/fa_FaCircle'), + params: { version: 'v6', iconName: 'FaCircle' }, + url: new URL('http://localhost:4321/api/v6/icons/FaCircle'), } as any) const body = await response.json() diff --git a/src/pages/api/[version]/icons/[iconName].ts b/src/pages/api/[version]/icons/[iconName].ts index bde1882..c377a28 100644 --- a/src/pages/api/[version]/icons/[iconName].ts +++ b/src/pages/api/[version]/icons/[iconName].ts @@ -4,18 +4,17 @@ import { createSvgResponse, } from '../../../../utils/apiHelpers' import { fetchApiIndex } from '../../../../utils/apiIndex/fetch' -import { fetchIconSvgs } from '../../../../utils/icons/fetch' -import { parseIconId } from '../../../../utils/icons/reactIcons' +import { fetchIconSvgs, fetchIconsIndex } from '../../../../utils/icons/fetch' export const prerender = false /** * GET /api/{version}/icons/[icon-name] * Returns actual SVG markup for the icon. - * Icon name format: {set}_{iconName} (e.g., fa_FaCircle, md_MdHome) + * Icon name: React component name (e.g., FaCircle, MdHome) */ export const GET: APIRoute = async ({ params, url }) => { - const { version, iconName: iconId } = params + const { version, iconName: reactName } = params if (!version) { return createJsonResponse( @@ -33,31 +32,28 @@ export const GET: APIRoute = async ({ params, url }) => { return createJsonResponse({ error: 'Failed to fetch API index' }, 500) } - if (!iconId) { + if (!reactName) { return createJsonResponse( { error: 'Icon name parameter is required' }, 400, ) } - const parsed = parseIconId(iconId) - if (!parsed) { + const icons = await fetchIconsIndex(url) + const icon = icons.find((i) => i.reactName === reactName) + if (!icon?.set) { return createJsonResponse( - { - error: 'Invalid icon name format', - expected: 'Use format {set}_{iconName} (e.g., fa_FaCircle, md_MdHome)', - }, - 400, + { error: `Icon '${reactName}' not found` }, + 404, ) } - const { setId, iconName } = parsed - const svgs = await fetchIconSvgs(url, setId) - const svg = svgs?.[iconName] ?? null + const svgs = await fetchIconSvgs(url, icon.set) + const svg = svgs?.[reactName] ?? null if (!svg) { return createJsonResponse( - { error: `Icon '${iconName}' not found in set '${setId}'` }, + { error: `Icon '${reactName}' not found in set '${icon.set}'` }, 404, ) } diff --git a/src/pages/api/index.ts b/src/pages/api/index.ts index 534022e..fbde11c 100644 --- a/src/pages/api/index.ts +++ b/src/pages/api/index.ts @@ -423,8 +423,8 @@ export const GET: APIRoute = async () => in: 'path', required: true, type: 'string', - description: 'Icon identifier in format {set}_{iconName} (e.g., fa_FaCircle, md_MdHome)', - example: 'fa_FaCircle', + description: 'Icon identifier: React component name (e.g., FaCircle, MdHome)', + example: 'FaCircle', }, ], returns: { diff --git a/src/pages/api/openapi.json.ts b/src/pages/api/openapi.json.ts index 081bc52..8e06b0a 100644 --- a/src/pages/api/openapi.json.ts +++ b/src/pages/api/openapi.json.ts @@ -165,7 +165,7 @@ export const GET: APIRoute = async ({ url }) => { get: { summary: 'Get icon SVG markup', description: - 'Returns actual SVG markup for the icon. Icon name format: {set}_{iconName} (e.g., fa_FaCircle, md_MdHome)', + 'Returns actual SVG markup for the icon. Icon name: React component name (e.g., FaCircle, MdHome)', operationId: 'getIconSvg', parameters: [ { @@ -180,8 +180,8 @@ export const GET: APIRoute = async ({ url }) => { in: 'path', required: true, schema: { type: 'string' }, - description: 'Icon identifier: {set}_{iconName}', - example: 'fa_FaCircle', + description: 'Icon identifier: React component name', + example: 'FaCircle', }, ], responses: { diff --git a/src/utils/icons/reactIcons.ts b/src/utils/icons/reactIcons.ts index 575727c..966d968 100644 --- a/src/utils/icons/reactIcons.ts +++ b/src/utils/icons/reactIcons.ts @@ -12,7 +12,7 @@ export interface IconMetadata { style: string usage: string unicode: string - /** Set id for SVG URL: /api/icons/{set}_{reactName} */ + /** Set id for SVG lookup (react name used in URL: /api/icons/{reactName}) */ set?: string } From b380631d1997b253ab6ba7fd437c0ea5d74434cb Mon Sep 17 00:00:00 2001 From: Donald Labaj Date: Mon, 16 Feb 2026 21:41:42 -0500 Subject: [PATCH 09/13] feat: Refactor icon utilities to use @patternfly/react-icons and update API endpoints accordingly --- .../[version]/icons/[iconName].test.ts | 26 +-- .../__tests__/[version]/icons/index.test.ts | 24 +-- src/pages/api/index.ts | 8 +- src/pages/api/openapi.json.ts | 8 +- src/pages/iconsIndex.json.ts | 3 +- src/pages/iconsSvgs/[setId].json.ts | 12 +- src/utils/icons/reactIcons.ts | 189 ++++++++---------- 7 files changed, 127 insertions(+), 143 deletions(-) diff --git a/src/__tests__/pages/api/__tests__/[version]/icons/[iconName].test.ts b/src/__tests__/pages/api/__tests__/[version]/icons/[iconName].test.ts index f912cab..d014199 100644 --- a/src/__tests__/pages/api/__tests__/[version]/icons/[iconName].test.ts +++ b/src/__tests__/pages/api/__tests__/[version]/icons/[iconName].test.ts @@ -10,12 +10,12 @@ const mockApiIndex = { const mockSvg = '' const mockIconSvgs: Record> = { - fa: { FaCircle: mockSvg }, + pf: { CircleIcon: mockSvg }, } const mockIconsIndex = { icons: [ - { name: 'circle', reactName: 'FaCircle', style: 'solid', usage: '', unicode: '', set: 'fa' }, + { name: 'circle', reactName: 'CircleIcon', style: 'pf', usage: '', unicode: '', set: 'pf' }, ], } @@ -48,8 +48,8 @@ it('returns SVG markup for valid icon', async () => { global.fetch = createFetchMock() const response = await GET({ - params: { version: 'v6', iconName: 'FaCircle' }, - url: new URL('http://localhost:4321/api/v6/icons/FaCircle'), + params: { version: 'v6', iconName: 'CircleIcon' }, + url: new URL('http://localhost:4321/api/v6/icons/CircleIcon'), } as any) const body = await response.text() @@ -67,14 +67,14 @@ it('returns 404 when icon is not found', async () => { global.fetch = createFetchMock() const response = await GET({ - params: { version: 'v6', iconName: 'FaNonExistent' }, - url: new URL('http://localhost:4321/api/v6/icons/FaNonExistent'), + params: { version: 'v6', iconName: 'NonExistentIcon' }, + url: new URL('http://localhost:4321/api/v6/icons/NonExistentIcon'), } as any) const body = await response.json() expect(response.status).toBe(404) expect(body).toHaveProperty('error') - expect(body.error).toContain('FaNonExistent') + expect(body.error).toContain('NonExistentIcon') expect(body.error).toContain('not found') jest.restoreAllMocks() @@ -117,8 +117,8 @@ it('returns 404 for nonexistent version', async () => { global.fetch = createFetchMock() const response = await GET({ - params: { version: 'v99', iconName: 'FaCircle' }, - url: new URL('http://localhost:4321/api/v99/icons/FaCircle'), + params: { version: 'v99', iconName: 'CircleIcon' }, + url: new URL('http://localhost:4321/api/v99/icons/CircleIcon'), } as any) const body = await response.json() @@ -134,8 +134,8 @@ it('returns 400 when version parameter is missing', async () => { global.fetch = createFetchMock() const response = await GET({ - params: { iconName: 'FaCircle' }, - url: new URL('http://localhost:4321/api/icons/FaCircle'), + params: { iconName: 'CircleIcon' }, + url: new URL('http://localhost:4321/api/icons/CircleIcon'), } as any) const body = await response.json() @@ -163,8 +163,8 @@ it('returns 500 when fetchApiIndex fails', async () => { }) as typeof fetch const response = await GET({ - params: { version: 'v6', iconName: 'FaCircle' }, - url: new URL('http://localhost:4321/api/v6/icons/FaCircle'), + params: { version: 'v6', iconName: 'CircleIcon' }, + url: new URL('http://localhost:4321/api/v6/icons/CircleIcon'), } as any) const body = await response.json() diff --git a/src/__tests__/pages/api/__tests__/[version]/icons/index.test.ts b/src/__tests__/pages/api/__tests__/[version]/icons/index.test.ts index f5b50d5..5b44790 100644 --- a/src/__tests__/pages/api/__tests__/[version]/icons/index.test.ts +++ b/src/__tests__/pages/api/__tests__/[version]/icons/index.test.ts @@ -10,27 +10,27 @@ const mockApiIndex = { const mockIcons = [ { name: 'circle', - reactName: 'FaCircle', - style: 'solid', - usage: "import { FaCircle } from 'react-icons/fa'", + reactName: 'CircleIcon', + style: 'pf', + usage: "import { CircleIcon } from '@patternfly/react-icons'", unicode: '', - set: 'fa', + set: 'pf', }, { name: 'home', - reactName: 'MdHome', - style: 'md', - usage: "import { MdHome } from 'react-icons/md'", + reactName: 'HomeIcon', + style: 'pf', + usage: "import { HomeIcon } from '@patternfly/react-icons'", unicode: '', - set: 'md', + set: 'pf', }, { name: 'circle-outline', - reactName: 'FaRegCircle', - style: 'regular', - usage: "import { FaRegCircle } from 'react-icons/fa'", + reactName: 'CircleOutlineIcon', + style: 'pf', + usage: "import { CircleOutlineIcon } from '@patternfly/react-icons'", unicode: '', - set: 'fa', + set: 'pf', }, ] diff --git a/src/pages/api/index.ts b/src/pages/api/index.ts index fbde11c..e0483bf 100644 --- a/src/pages/api/index.ts +++ b/src/pages/api/index.ts @@ -370,7 +370,7 @@ export const GET: APIRoute = async () => { path: '/api/{version}/icons', method: 'GET', - description: 'List all available icons with metadata from react-icons', + description: 'List all available icons with metadata from @patternfly/react-icons', parameters: [ { name: 'version', @@ -395,9 +395,9 @@ export const GET: APIRoute = async () => icons: [ { name: 'circle', - reactName: 'FaCircle', - style: 'solid', - usage: "import { FaCircle } from 'react-icons/fa'", + reactName: 'CircleIcon', + style: 'pf', + usage: "import { CircleIcon } from '@patternfly/react-icons'", unicode: '', }, ], diff --git a/src/pages/api/openapi.json.ts b/src/pages/api/openapi.json.ts index 8e06b0a..e71101f 100644 --- a/src/pages/api/openapi.json.ts +++ b/src/pages/api/openapi.json.ts @@ -108,7 +108,7 @@ export const GET: APIRoute = async ({ url }) => { get: { summary: 'List available icons', description: - 'Returns list of all available icons from react-icons with metadata. Use filter query param to filter by name.', + 'Returns list of all available icons from @patternfly/react-icons (dist/static) with metadata. Use filter query param to filter by name.', operationId: 'getIcons', parameters: [ { @@ -141,11 +141,11 @@ export const GET: APIRoute = async ({ url }) => { type: 'object', properties: { name: { type: 'string', example: 'circle' }, - reactName: { type: 'string', example: 'FaCircle' }, - style: { type: 'string', example: 'solid' }, + reactName: { type: 'string', example: 'CircleIcon' }, + style: { type: 'string', example: 'pf' }, usage: { type: 'string', - example: "import { FaCircle } from 'react-icons/fa'", + example: "import { CircleIcon } from '@patternfly/react-icons'", }, unicode: { type: 'string', example: '' }, }, diff --git a/src/pages/iconsIndex.json.ts b/src/pages/iconsIndex.json.ts index 52e724f..b10be0f 100644 --- a/src/pages/iconsIndex.json.ts +++ b/src/pages/iconsIndex.json.ts @@ -3,8 +3,7 @@ import { getAllIcons } from '../utils/icons/reactIcons' /** * Prerender at build time so this doesn't run in the Cloudflare Worker. - * getAllIcons() uses dynamic imports of react-icons which fail in Workers - * due to bundle size and Node.js compatibility. + * getAllIcons() reads from @patternfly/react-icons/dist/static (Node fs). */ export const prerender = true diff --git a/src/pages/iconsSvgs/[setId].json.ts b/src/pages/iconsSvgs/[setId].json.ts index 9c150cc..561beeb 100644 --- a/src/pages/iconsSvgs/[setId].json.ts +++ b/src/pages/iconsSvgs/[setId].json.ts @@ -1,17 +1,17 @@ import type { APIRoute, GetStaticPaths } from 'astro' -import { IconsManifest } from 'react-icons/lib' import { getIconSvgsForSet } from '../../utils/icons/reactIcons' +const PF_ICONS_SET_ID = 'pf' + /** * Prerender at build time so this doesn't run in the Cloudflare Worker. - * getIconSvgsForSet() uses dynamic imports of react-icons which fail in Workers. + * getIconSvgsForSet() reads from @patternfly/react-icons/dist/static. */ export const prerender = true -export const getStaticPaths: GetStaticPaths = async () => - IconsManifest.map((m) => ({ - params: { setId: m.id }, - })) +export const getStaticPaths: GetStaticPaths = async () => [ + { params: { setId: PF_ICONS_SET_ID } }, +] export const GET: APIRoute = async ({ params }) => { const { setId } = params diff --git a/src/utils/icons/reactIcons.ts b/src/utils/icons/reactIcons.ts index 966d968..e754dc9 100644 --- a/src/utils/icons/reactIcons.ts +++ b/src/utils/icons/reactIcons.ts @@ -1,10 +1,12 @@ /** - * Utilities for working with react-icons from the ESM package. - * Icons are loaded from node_modules/react-icons icon set folders. + * Utilities for working with @patternfly/react-icons. + * Icons are loaded from @patternfly/react-icons/dist/static (SVG files). */ -import { renderToStaticMarkup } from 'react-dom/server' -import React from 'react' -import { IconsManifest } from 'react-icons/lib' +import fs from 'fs' +import path from 'path' +import { fileURLToPath } from 'url' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) export interface IconMetadata { name: string @@ -12,80 +14,68 @@ export interface IconMetadata { style: string usage: string unicode: string - /** Set id for SVG lookup (react name used in URL: /api/icons/{reactName}) */ + /** Set id for SVG lookup (used internally by API) */ set?: string } -const ICON_SET_IDS = IconsManifest.map((m) => m.id) - -/** Derive style from set id and react name (e.g., fa + FaRegCircle -> "regular") */ -function getStyle(setId: string, reactName: string): string { - if (setId === 'fa' || setId === 'fa6') { - if (reactName.startsWith('FaReg')) { - return 'regular' - } - if (reactName.startsWith('FaBrands')) { - return 'brands' - } - return 'solid' - } - return setId +const PF_ICONS_SET_ID = 'pf' + +/** Resolve path to @patternfly/react-icons/dist/static (from project root). */ +function getStaticIconsDir(): string { + const projectRoot = path.resolve(__dirname, '../..') + return path.join( + projectRoot, + 'node_modules', + '@patternfly', + 'react-icons', + 'dist', + 'static', + ) +} + +/** Convert kebab-case filename (no .svg) to PatternFly React component name (PascalCase + "Icon"). */ +function kebabToReactName(kebab: string): string { + const pascal = kebab + .split('-') + .map((part) => part.charAt(0).toUpperCase() + part.slice(1).toLowerCase()) + .join('') + return pascal + 'Icon' } -/** Convert PascalCase to kebab-case */ -function toKebabCase(str: string): string { - return str +/** Convert React component name back to kebab-case (without "Icon" suffix). */ +function reactNameToKebab(reactName: string): string { + const withoutIcon = reactName.replace(/Icon$/, '') + return withoutIcon .replace(/([a-z])([A-Z])/g, '$1-$2') .replace(/([A-Z])([A-Z][a-z])/g, '$1-$2') .toLowerCase() } -/** Derive base name from react name by removing set-specific prefixes */ -function getBaseName(setId: string, reactName: string): string { - let base = reactName - if (setId === 'fa' || setId === 'fa6') { - base = base.replace(/^Fa(Reg|Brands)?/, '') - } else if (setId === 'io' || setId === 'io5') { - base = base.replace(/^Io(5)?/, '') - } else if (setId === 'md') { - base = base.replace(/^Md/, '') - } else if (setId === 'hi' || setId === 'hi2') { - base = base.replace(/^Hi(2)?/, '') - } else { - const setPrefix = setId.charAt(0).toUpperCase() + setId.slice(1) - const prefix = new RegExp(`^${setPrefix}`, 'i') - base = base.replace(prefix, '') - } - return toKebabCase(base) || toKebabCase(reactName) -} - /** - * Get all icons from all sets with metadata. + * Get all icons from @patternfly/react-icons/dist/static with metadata. * Shape: { name, reactName, style, usage, unicode } */ export async function getAllIcons(): Promise { + const staticDir = getStaticIconsDir() + if (!fs.existsSync(staticDir)) { + return [] + } + + const files = fs.readdirSync(staticDir) + const svgFiles = files.filter((f) => f.endsWith('.svg')) const icons: IconMetadata[] = [] - for (const setId of ICON_SET_IDS) { - try { - const module = await import(`react-icons/${setId}`) - const iconNames = Object.keys(module).filter( - (k) => typeof module[k] === 'function' && k !== 'default', - ) - - for (const reactName of iconNames) { - icons.push({ - name: getBaseName(setId, reactName), - reactName, - style: getStyle(setId, reactName), - usage: `import { ${reactName} } from 'react-icons/${setId}'`, - unicode: '', - set: setId, - }) - } - } catch { - // Skip sets that fail to load - } + for (const file of svgFiles) { + const name = file.replace(/\.svg$/, '') + const reactName = kebabToReactName(name) + icons.push({ + name, + reactName, + style: PF_ICONS_SET_ID, + usage: `import { ${reactName} } from '@patternfly/react-icons'`, + unicode: '', + set: PF_ICONS_SET_ID, + }) } return icons @@ -110,74 +100,69 @@ export function filterIcons( } /** - * Get SVG markup for all icons in a set. Used at build time for prerendering. - * @param setId - Icon set id (e.g., "fa", "md") - * @returns Record of iconName -> SVG string + * Get SVG markup for all PatternFly icons (single set). + * Used at build time for prerendering. + * @param setId - Must be "pf" for PatternFly icons + * @returns Record of reactName -> SVG string */ export async function getIconSvgsForSet( setId: string, ): Promise> { - if (!ICON_SET_IDS.includes(setId)) { + if (setId !== PF_ICONS_SET_ID) { return {} } - try { - const module = await import(`react-icons/${setId}`) - const svgs: Record = {} - - for (const iconName of Object.keys(module)) { - const IconComponent = module[iconName] - if (typeof IconComponent !== 'function' || iconName === 'default') { - continue - } - - const element = React.createElement(IconComponent, { - size: '1em', - style: { verticalAlign: 'middle' }, - }) - svgs[iconName] = renderToStaticMarkup(element) - } - - return svgs - } catch { + const staticDir = getStaticIconsDir() + if (!fs.existsSync(staticDir)) { return {} } + + const files = fs.readdirSync(staticDir) + const svgFiles = files.filter((f) => f.endsWith('.svg')) + const svgs: Record = {} + + for (const file of svgFiles) { + const name = file.replace(/\.svg$/, '') + const reactName = kebabToReactName(name) + const filePath = path.join(staticDir, file) + const content = fs.readFileSync(filePath, 'utf-8') + svgs[reactName] = content.trim() + } + + return svgs } /** * Get SVG markup for a specific icon. - * @param setId - Icon set id (e.g., "fa", "md") - * @param iconName - Icon component name (e.g., "FaCircle") + * @param setId - Must be "pf" + * @param iconName - React component name (e.g., "AccessibleIconIcon") */ export async function getIconSvg( setId: string, iconName: string, ): Promise { - if (!ICON_SET_IDS.includes(setId)) { + if (setId !== PF_ICONS_SET_ID) { return null } - try { - const module = await import(`react-icons/${setId}`) - const IconComponent = module[iconName] - if (typeof IconComponent !== 'function') { - return null - } + const kebab = reactNameToKebab(iconName) + const fileName = `${kebab}.svg` + const staticDir = getStaticIconsDir() + const filePath = path.join(staticDir, fileName) - const element = React.createElement(IconComponent, { - size: '1em', - style: { verticalAlign: 'middle' }, - }) - return renderToStaticMarkup(element) - } catch { + if (!fs.existsSync(filePath)) { return null } + + return fs.readFileSync(filePath, 'utf-8').trim() } /** * Parse icon id "set_iconName" into { setId, iconName } */ -export function parseIconId(iconId: string): { setId: string; iconName: string } | null { +export function parseIconId( + iconId: string, +): { setId: string; iconName: string } | null { const underscoreIndex = iconId.indexOf('_') if (underscoreIndex <= 0) { return null From 2b8c40c633414af17917acc21067d780a3d8df72 Mon Sep 17 00:00:00 2001 From: Donald Labaj Date: Mon, 16 Feb 2026 21:42:34 -0500 Subject: [PATCH 10/13] updated to use prerelease to get static icons. --- package-lock.json | 77 +++++++++++++++++++++++++++++++++++++++++++++-- package.json | 2 +- 2 files changed, 76 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 390441d..cd74b9a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,7 +20,7 @@ "@patternfly/react-code-editor": "^6.2.2", "@patternfly/react-core": "^6.0.0", "@patternfly/react-drag-drop": "^6.0.0", - "@patternfly/react-icons": "^6.0.0", + "@patternfly/react-icons": "6.5.0-prerelease.13", "@patternfly/react-styles": "^6.0.0", "@patternfly/react-table": "^6.0.0", "@patternfly/react-tokens": "^6.0.0", @@ -4085,6 +4085,16 @@ "react-dom": "^17 || ^18" } }, + "node_modules/@patternfly/react-code-editor/node_modules/@patternfly/react-icons": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/@patternfly/react-icons/-/react-icons-6.4.0.tgz", + "integrity": "sha512-SPjzatm73NUYv/BL6A/cjRA5sFQ15NkiyPAcT8gmklI7HY+ptd6/eg49uBDFmxTQcSwbb5ISW/R6wwCQBY2M+Q==", + "license": "MIT", + "peerDependencies": { + "react": "^17 || ^18 || ^19", + "react-dom": "^17 || ^18 || ^19" + } + }, "node_modules/@patternfly/react-component-groups": { "version": "6.4.0", "resolved": "https://registry.npmjs.org/@patternfly/react-component-groups/-/react-component-groups-6.4.0.tgz", @@ -4104,6 +4114,17 @@ "react-dom": "^17 || ^18 || ^19" } }, + "node_modules/@patternfly/react-component-groups/node_modules/@patternfly/react-icons": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/@patternfly/react-icons/-/react-icons-6.4.0.tgz", + "integrity": "sha512-SPjzatm73NUYv/BL6A/cjRA5sFQ15NkiyPAcT8gmklI7HY+ptd6/eg49uBDFmxTQcSwbb5ISW/R6wwCQBY2M+Q==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "react": "^17 || ^18 || ^19", + "react-dom": "^17 || ^18 || ^19" + } + }, "node_modules/@patternfly/react-core": { "version": "6.4.0", "resolved": "https://registry.npmjs.org/@patternfly/react-core/-/react-core-6.4.0.tgz", @@ -4122,6 +4143,16 @@ "react-dom": "^17 || ^18 || ^19" } }, + "node_modules/@patternfly/react-core/node_modules/@patternfly/react-icons": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/@patternfly/react-icons/-/react-icons-6.4.0.tgz", + "integrity": "sha512-SPjzatm73NUYv/BL6A/cjRA5sFQ15NkiyPAcT8gmklI7HY+ptd6/eg49uBDFmxTQcSwbb5ISW/R6wwCQBY2M+Q==", + "license": "MIT", + "peerDependencies": { + "react": "^17 || ^18 || ^19", + "react-dom": "^17 || ^18 || ^19" + } + }, "node_modules/@patternfly/react-data-view": { "version": "6.4.0", "resolved": "https://registry.npmjs.org/@patternfly/react-data-view/-/react-data-view-6.4.0.tgz", @@ -4141,6 +4172,17 @@ "react-dom": "^17 || ^18 || ^19" } }, + "node_modules/@patternfly/react-data-view/node_modules/@patternfly/react-icons": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/@patternfly/react-icons/-/react-icons-6.4.0.tgz", + "integrity": "sha512-SPjzatm73NUYv/BL6A/cjRA5sFQ15NkiyPAcT8gmklI7HY+ptd6/eg49uBDFmxTQcSwbb5ISW/R6wwCQBY2M+Q==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "react": "^17 || ^18 || ^19", + "react-dom": "^17 || ^18 || ^19" + } + }, "node_modules/@patternfly/react-drag-drop": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/@patternfly/react-drag-drop/-/react-drag-drop-6.2.2.tgz", @@ -4160,7 +4202,7 @@ "react-dom": "^17 || ^18" } }, - "node_modules/@patternfly/react-icons": { + "node_modules/@patternfly/react-drag-drop/node_modules/@patternfly/react-icons": { "version": "6.4.0", "resolved": "https://registry.npmjs.org/@patternfly/react-icons/-/react-icons-6.4.0.tgz", "integrity": "sha512-SPjzatm73NUYv/BL6A/cjRA5sFQ15NkiyPAcT8gmklI7HY+ptd6/eg49uBDFmxTQcSwbb5ISW/R6wwCQBY2M+Q==", @@ -4170,6 +4212,16 @@ "react-dom": "^17 || ^18 || ^19" } }, + "node_modules/@patternfly/react-icons": { + "version": "6.5.0-prerelease.13", + "resolved": "https://registry.npmjs.org/@patternfly/react-icons/-/react-icons-6.5.0-prerelease.13.tgz", + "integrity": "sha512-40eSxfFytIAQkQ9EM6K4rqdDHIL9AwivqUbsYHZqJPNoipkL8RukxegPr7Lzvwt9kZ6OWGmTPtGySd4BkXzAqg==", + "license": "MIT", + "peerDependencies": { + "react": "^17 || ^18 || ^19", + "react-dom": "^17 || ^18 || ^19" + } + }, "node_modules/@patternfly/react-styles": { "version": "6.4.0", "resolved": "https://registry.npmjs.org/@patternfly/react-styles/-/react-styles-6.4.0.tgz", @@ -4194,6 +4246,16 @@ "react-dom": "^17 || ^18 || ^19" } }, + "node_modules/@patternfly/react-table/node_modules/@patternfly/react-icons": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/@patternfly/react-icons/-/react-icons-6.4.0.tgz", + "integrity": "sha512-SPjzatm73NUYv/BL6A/cjRA5sFQ15NkiyPAcT8gmklI7HY+ptd6/eg49uBDFmxTQcSwbb5ISW/R6wwCQBY2M+Q==", + "license": "MIT", + "peerDependencies": { + "react": "^17 || ^18 || ^19", + "react-dom": "^17 || ^18 || ^19" + } + }, "node_modules/@patternfly/react-tokens": { "version": "6.4.0", "resolved": "https://registry.npmjs.org/@patternfly/react-tokens/-/react-tokens-6.4.0.tgz", @@ -4215,6 +4277,17 @@ "react-dom": "^17 || ^18 || ^19" } }, + "node_modules/@patternfly/react-user-feedback/node_modules/@patternfly/react-icons": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/@patternfly/react-icons/-/react-icons-6.4.0.tgz", + "integrity": "sha512-SPjzatm73NUYv/BL6A/cjRA5sFQ15NkiyPAcT8gmklI7HY+ptd6/eg49uBDFmxTQcSwbb5ISW/R6wwCQBY2M+Q==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "react": "^17 || ^18 || ^19", + "react-dom": "^17 || ^18 || ^19" + } + }, "node_modules/@pkgr/core": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.1.1.tgz", diff --git a/package.json b/package.json index c1fe88b..9b695c5 100644 --- a/package.json +++ b/package.json @@ -59,7 +59,7 @@ "@patternfly/react-code-editor": "^6.2.2", "@patternfly/react-core": "^6.0.0", "@patternfly/react-drag-drop": "^6.0.0", - "@patternfly/react-icons": "^6.0.0", + "@patternfly/react-icons": "6.5.0-prerelease.13", "@patternfly/react-styles": "^6.0.0", "@patternfly/react-table": "^6.0.0", "@patternfly/react-tokens": "^6.0.0", From 160618a24e66fdc97653d48de76a7e5d394369d2 Mon Sep 17 00:00:00 2001 From: Donald Labaj Date: Mon, 16 Feb 2026 21:59:28 -0500 Subject: [PATCH 11/13] Updates from review. --- astro.config.mjs | 6 ++++-- .../[version]/icons/[iconName].test.ts | 2 +- .../api/__tests__/[version]/icons/index.test.ts | 11 ----------- src/pages/api/[version]/icons/[iconName].ts | 6 +++--- src/pages/api/index.ts | 4 +--- src/pages/api/openapi.json.ts | 2 -- src/utils/icons/reactIcons.ts | 17 +++-------------- 7 files changed, 12 insertions(+), 36 deletions(-) diff --git a/astro.config.mjs b/astro.config.mjs index 7d5c233..21521de 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -11,7 +11,7 @@ export default defineConfig({ vite: { ssr: { noExternal: ["@patternfly/*", "react-dropzone"], - external: ["node:fs", "node:path", "fs/promises", "path"] + external: ["fs", "node:fs", "node:path", "path", "fs/promises"] }, server: { fs: { @@ -19,5 +19,7 @@ export default defineConfig({ } }, }, - adapter: cloudflare() + adapter: cloudflare({ + imageService: 'compile' + }) }); \ No newline at end of file diff --git a/src/__tests__/pages/api/__tests__/[version]/icons/[iconName].test.ts b/src/__tests__/pages/api/__tests__/[version]/icons/[iconName].test.ts index d014199..1ca8a10 100644 --- a/src/__tests__/pages/api/__tests__/[version]/icons/[iconName].test.ts +++ b/src/__tests__/pages/api/__tests__/[version]/icons/[iconName].test.ts @@ -15,7 +15,7 @@ const mockIconSvgs: Record> = { const mockIconsIndex = { icons: [ - { name: 'circle', reactName: 'CircleIcon', style: 'pf', usage: '', unicode: '', set: 'pf' }, + { name: 'circle', reactName: 'CircleIcon', usage: '' }, ], } diff --git a/src/__tests__/pages/api/__tests__/[version]/icons/index.test.ts b/src/__tests__/pages/api/__tests__/[version]/icons/index.test.ts index 5b44790..8a73faa 100644 --- a/src/__tests__/pages/api/__tests__/[version]/icons/index.test.ts +++ b/src/__tests__/pages/api/__tests__/[version]/icons/index.test.ts @@ -11,26 +11,17 @@ const mockIcons = [ { name: 'circle', reactName: 'CircleIcon', - style: 'pf', usage: "import { CircleIcon } from '@patternfly/react-icons'", - unicode: '', - set: 'pf', }, { name: 'home', reactName: 'HomeIcon', - style: 'pf', usage: "import { HomeIcon } from '@patternfly/react-icons'", - unicode: '', - set: 'pf', }, { name: 'circle-outline', reactName: 'CircleOutlineIcon', - style: 'pf', usage: "import { CircleOutlineIcon } from '@patternfly/react-icons'", - unicode: '', - set: 'pf', }, ] @@ -79,9 +70,7 @@ it('returns all icons with metadata', async () => { expect(body.total).toBe(3) expect(body.icons[0]).toHaveProperty('name') expect(body.icons[0]).toHaveProperty('reactName') - expect(body.icons[0]).toHaveProperty('style') expect(body.icons[0]).toHaveProperty('usage') - expect(body.icons[0]).toHaveProperty('unicode') jest.restoreAllMocks() }) diff --git a/src/pages/api/[version]/icons/[iconName].ts b/src/pages/api/[version]/icons/[iconName].ts index c377a28..5d26b07 100644 --- a/src/pages/api/[version]/icons/[iconName].ts +++ b/src/pages/api/[version]/icons/[iconName].ts @@ -41,19 +41,19 @@ export const GET: APIRoute = async ({ params, url }) => { const icons = await fetchIconsIndex(url) const icon = icons.find((i) => i.reactName === reactName) - if (!icon?.set) { + if (!icon) { return createJsonResponse( { error: `Icon '${reactName}' not found` }, 404, ) } - const svgs = await fetchIconSvgs(url, icon.set) + const svgs = await fetchIconSvgs(url, 'pf') const svg = svgs?.[reactName] ?? null if (!svg) { return createJsonResponse( - { error: `Icon '${reactName}' not found in set '${icon.set}'` }, + { error: `Icon '${reactName}' not found` }, 404, ) } diff --git a/src/pages/api/index.ts b/src/pages/api/index.ts index e0483bf..db0d70e 100644 --- a/src/pages/api/index.ts +++ b/src/pages/api/index.ts @@ -390,15 +390,13 @@ export const GET: APIRoute = async () => ], returns: { type: 'object', - description: 'List of icons with name, reactName, style, usage, unicode', + description: 'List of icons with name, reactName, usage', example: { icons: [ { name: 'circle', reactName: 'CircleIcon', - style: 'pf', usage: "import { CircleIcon } from '@patternfly/react-icons'", - unicode: '', }, ], total: 1, diff --git a/src/pages/api/openapi.json.ts b/src/pages/api/openapi.json.ts index e71101f..68a33cd 100644 --- a/src/pages/api/openapi.json.ts +++ b/src/pages/api/openapi.json.ts @@ -142,12 +142,10 @@ export const GET: APIRoute = async ({ url }) => { properties: { name: { type: 'string', example: 'circle' }, reactName: { type: 'string', example: 'CircleIcon' }, - style: { type: 'string', example: 'pf' }, usage: { type: 'string', example: "import { CircleIcon } from '@patternfly/react-icons'", }, - unicode: { type: 'string', example: '' }, }, }, }, diff --git a/src/utils/icons/reactIcons.ts b/src/utils/icons/reactIcons.ts index e754dc9..000518f 100644 --- a/src/utils/icons/reactIcons.ts +++ b/src/utils/icons/reactIcons.ts @@ -4,27 +4,19 @@ */ import fs from 'fs' import path from 'path' -import { fileURLToPath } from 'url' - -const __dirname = path.dirname(fileURLToPath(import.meta.url)) export interface IconMetadata { name: string reactName: string - style: string usage: string - unicode: string - /** Set id for SVG lookup (used internally by API) */ - set?: string } const PF_ICONS_SET_ID = 'pf' -/** Resolve path to @patternfly/react-icons/dist/static (from project root). */ +/** Resolve path to @patternfly/react-icons/dist/static. Uses cwd so it works in dev and build. */ function getStaticIconsDir(): string { - const projectRoot = path.resolve(__dirname, '../..') return path.join( - projectRoot, + process.cwd(), 'node_modules', '@patternfly', 'react-icons', @@ -53,7 +45,7 @@ function reactNameToKebab(reactName: string): string { /** * Get all icons from @patternfly/react-icons/dist/static with metadata. - * Shape: { name, reactName, style, usage, unicode } + * Shape: { name, reactName, usage } */ export async function getAllIcons(): Promise { const staticDir = getStaticIconsDir() @@ -71,10 +63,7 @@ export async function getAllIcons(): Promise { icons.push({ name, reactName, - style: PF_ICONS_SET_ID, usage: `import { ${reactName} } from '@patternfly/react-icons'`, - unicode: '', - set: PF_ICONS_SET_ID, }) } From 9c287db55469172d6cdb4e77bf881f1d5df302ef Mon Sep 17 00:00:00 2001 From: Donald Labaj Date: Tue, 17 Feb 2026 16:28:30 -0500 Subject: [PATCH 12/13] Updated with review comments. --- .../[version]/icons/[iconName].test.ts | 2 +- src/pages/api/[version]/icons/[iconName].ts | 2 +- src/pages/api/[version]/icons/[setId].json.ts | 42 +++++++++++++++++ src/pages/iconsSvgs/[setId].json.ts | 46 ------------------- src/utils/apiIndex/get.ts | 39 ++++++++++++++++ src/utils/icons/fetch.ts | 12 +++-- 6 files changed, 91 insertions(+), 52 deletions(-) create mode 100644 src/pages/api/[version]/icons/[setId].json.ts delete mode 100644 src/pages/iconsSvgs/[setId].json.ts diff --git a/src/__tests__/pages/api/__tests__/[version]/icons/[iconName].test.ts b/src/__tests__/pages/api/__tests__/[version]/icons/[iconName].test.ts index 1ca8a10..c9c2840 100644 --- a/src/__tests__/pages/api/__tests__/[version]/icons/[iconName].test.ts +++ b/src/__tests__/pages/api/__tests__/[version]/icons/[iconName].test.ts @@ -28,7 +28,7 @@ function createFetchMock(): typeof fetch { json: () => Promise.resolve(mockIconsIndex), } as Response) } - const match = url.match(/\/iconsSvgs\/([^/]+)\.json/) + const match = url.match(/\/api\/[^/]+\/icons\/([^/]+)\.json/) if (match) { const setId = match[1] const svgs = mockIconSvgs[setId] ?? {} diff --git a/src/pages/api/[version]/icons/[iconName].ts b/src/pages/api/[version]/icons/[iconName].ts index 5d26b07..cc16c45 100644 --- a/src/pages/api/[version]/icons/[iconName].ts +++ b/src/pages/api/[version]/icons/[iconName].ts @@ -48,7 +48,7 @@ export const GET: APIRoute = async ({ params, url }) => { ) } - const svgs = await fetchIconSvgs(url, 'pf') + const svgs = await fetchIconSvgs(url, version, 'pf', assetsFetch) const svg = svgs?.[reactName] ?? null if (!svg) { diff --git a/src/pages/api/[version]/icons/[setId].json.ts b/src/pages/api/[version]/icons/[setId].json.ts new file mode 100644 index 0000000..e375d96 --- /dev/null +++ b/src/pages/api/[version]/icons/[setId].json.ts @@ -0,0 +1,42 @@ +import type { APIRoute, GetStaticPaths } from 'astro' +import { getVersionsFromIndexFile } from '../../../../utils/apiIndex/get' +import { createJsonResponse } from '../../../../utils/apiHelpers' +import { getIconSvgsForSet } from '../../../../utils/icons/reactIcons' + +/** + * Prerender at build time so this doesn't run in the Cloudflare Worker. + * getIconSvgsForSet() reads from @patternfly/react-icons/dist/static (Node fs). + * Serves JSON of all icon SVGs for a set (e.g. /api/v5/icons/pf.json). + */ +export const prerender = true + +export const getStaticPaths: GetStaticPaths = async () => { + const versions = await getVersionsFromIndexFile() + return versions.flatMap((version) => [ + { params: { version, setId: 'pf' } }, + ]) +} + +export const GET: APIRoute = async ({ params }) => { + const { version, setId } = params + if (!version) { + return createJsonResponse( + { error: 'Version parameter is required' }, + 400, + ) + } + if (!setId) { + return createJsonResponse({ error: 'Set ID is required' }, 400) + } + + try { + const svgs = await getIconSvgsForSet(setId) + return createJsonResponse(svgs) + } catch (error) { + const details = error instanceof Error ? error.message : String(error) + return createJsonResponse( + { error: 'Failed to load icon SVGs', details }, + 500, + ) + } +} diff --git a/src/pages/iconsSvgs/[setId].json.ts b/src/pages/iconsSvgs/[setId].json.ts deleted file mode 100644 index 561beeb..0000000 --- a/src/pages/iconsSvgs/[setId].json.ts +++ /dev/null @@ -1,46 +0,0 @@ -import type { APIRoute, GetStaticPaths } from 'astro' -import { getIconSvgsForSet } from '../../utils/icons/reactIcons' - -const PF_ICONS_SET_ID = 'pf' - -/** - * Prerender at build time so this doesn't run in the Cloudflare Worker. - * getIconSvgsForSet() reads from @patternfly/react-icons/dist/static. - */ -export const prerender = true - -export const getStaticPaths: GetStaticPaths = async () => [ - { params: { setId: PF_ICONS_SET_ID } }, -] - -export const GET: APIRoute = async ({ params }) => { - const { setId } = params - - if (!setId) { - return new Response( - JSON.stringify({ error: 'Set ID is required' }), - { status: 400, headers: { 'Content-Type': 'application/json' } } - ) - } - - try { - const svgs = await getIconSvgsForSet(setId) - return new Response(JSON.stringify(svgs), { - status: 200, - headers: { - 'Content-Type': 'application/json', - }, - }) - } catch (error) { - return new Response( - JSON.stringify({ - error: 'Failed to load icon SVGs', - details: String(error), - }), - { - status: 500, - headers: { 'Content-Type': 'application/json' }, - } - ) - } -} diff --git a/src/utils/apiIndex/get.ts b/src/utils/apiIndex/get.ts index 8428644..52cf9b2 100644 --- a/src/utils/apiIndex/get.ts +++ b/src/utils/apiIndex/get.ts @@ -52,6 +52,45 @@ export async function getApiIndex(): Promise { } } +/** + * Reads only the versions array from the API index file. + * Use this when the full index may not be generated yet or has a minimal structure + * (e.g. during getStaticPaths for icon routes). Does not validate examples/css. + * + * @returns Promise resolving to array of version strings (e.g., ['v5', 'v6']) + * @throws Error if the index file is missing or has no valid "versions" array + */ +export async function getVersionsFromIndexFile(): Promise { + const outputDir = await getOutputDir() + const indexPath = join(outputDir, 'apiIndex.json') + + try { + const content = await readFile(indexPath, 'utf-8') + const parsed = JSON.parse(content) + + if (!parsed.versions || !Array.isArray(parsed.versions)) { + throw new Error( + `Invalid API index structure at ${indexPath}: missing or invalid "versions" array`, + ) + } + + return parsed.versions + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + throw new Error( + `API index file not found at ${indexPath}. ` + + 'Please run the build process to generate the index.', + ) + } + if (error instanceof SyntaxError) { + throw new Error( + `API index contains invalid JSON at ${indexPath}. Please rebuild to regenerate the index file.`, + ) + } + throw error + } +} + /** * Gets all available documentation versions * diff --git a/src/utils/icons/fetch.ts b/src/utils/icons/fetch.ts index 5963772..beafe2f 100644 --- a/src/utils/icons/fetch.ts +++ b/src/utils/icons/fetch.ts @@ -32,15 +32,19 @@ export async function fetchIconsIndex(url: URL): Promise { * uses dynamic imports that fail in Cloudflare Workers. * * @param url - The URL object from the API route context - * @param setId - Icon set id (e.g., "fa", "ci") - * @returns Promise resolving to Record of iconName -> SVG string, or null if fetch fails + * @param version - Docs version (e.g. "v5") + * @param setId - Icon set id (e.g. "pf") + * @param assetsFetch - Optional; when provided (e.g. locals.runtime.env.ASSETS.fetch on Cloudflare), use it to fetch the asset */ export async function fetchIconSvgs( url: URL, + version: string, setId: string, ): Promise | null> { - const iconsSvgsUrl = new URL(`/iconsSvgs/${setId}.json`, url.origin) - const response = await fetch(iconsSvgsUrl) + const iconsSvgsUrl = new URL(`/api/${version}/icons/${setId}.json`, url.origin) + const response = assetsFetch + ? await assetsFetch(new Request(iconsSvgsUrl)) + : await fetch(iconsSvgsUrl) if (!response.ok) { return null From ce4583dce390c3d386cb0582608bba151b02dad2 Mon Sep 17 00:00:00 2001 From: Donald Labaj Date: Wed, 18 Feb 2026 16:22:20 -0500 Subject: [PATCH 13/13] Fix issue with fs on cloudflare for the /icons endpoint. --- .../icons/{[setId].json.ts => [iconSet].ts} | 12 +++---- src/pages/api/[version]/icons/index.ts | 20 +++++++++++- src/utils/icons/fetch.ts | 4 +-- src/utils/icons/icons.ts | 7 ++++ src/utils/icons/reactIcons.ts | 32 ++----------------- wrangler.jsonc | 2 +- 6 files changed, 38 insertions(+), 39 deletions(-) rename src/pages/api/[version]/icons/{[setId].json.ts => [iconSet].ts} (78%) create mode 100644 src/utils/icons/icons.ts diff --git a/src/pages/api/[version]/icons/[setId].json.ts b/src/pages/api/[version]/icons/[iconSet].ts similarity index 78% rename from src/pages/api/[version]/icons/[setId].json.ts rename to src/pages/api/[version]/icons/[iconSet].ts index e375d96..3a2e186 100644 --- a/src/pages/api/[version]/icons/[setId].json.ts +++ b/src/pages/api/[version]/icons/[iconSet].ts @@ -6,31 +6,31 @@ import { getIconSvgsForSet } from '../../../../utils/icons/reactIcons' /** * Prerender at build time so this doesn't run in the Cloudflare Worker. * getIconSvgsForSet() reads from @patternfly/react-icons/dist/static (Node fs). - * Serves JSON of all icon SVGs for a set (e.g. /api/v5/icons/pf.json). + * Serves JSON of all icon SVGs for a set (e.g. /api/v6/icons/pf). */ export const prerender = true export const getStaticPaths: GetStaticPaths = async () => { const versions = await getVersionsFromIndexFile() return versions.flatMap((version) => [ - { params: { version, setId: 'pf' } }, + { params: { version, iconSet: 'pf' } }, ]) } export const GET: APIRoute = async ({ params }) => { - const { version, setId } = params + const { version, iconSet } = params if (!version) { return createJsonResponse( { error: 'Version parameter is required' }, 400, ) } - if (!setId) { - return createJsonResponse({ error: 'Set ID is required' }, 400) + if (!iconSet) { + return createJsonResponse({ error: 'Icon set is required' }, 400) } try { - const svgs = await getIconSvgsForSet(setId) + const svgs = await getIconSvgsForSet(iconSet) return createJsonResponse(svgs) } catch (error) { const details = error instanceof Error ? error.message : String(error) diff --git a/src/pages/api/[version]/icons/index.ts b/src/pages/api/[version]/icons/index.ts index 93105f5..85be202 100644 --- a/src/pages/api/[version]/icons/index.ts +++ b/src/pages/api/[version]/icons/index.ts @@ -2,10 +2,28 @@ import type { APIRoute } from 'astro' import { createJsonResponse } from '../../../../utils/apiHelpers' import { fetchApiIndex } from '../../../../utils/apiIndex/fetch' import { fetchIconsIndex } from '../../../../utils/icons/fetch' -import { filterIcons } from '../../../../utils/icons/reactIcons' +import { IconMetadata } from '../../../../utils/icons/icons' export const prerender = false +/** + * Filter icons by search term (case-insensitive match on name or reactName) + */ +export function filterIcons( + icons: IconMetadata[], + filter: string, +): IconMetadata[] { + if (!filter || !filter.trim()) { + return icons + } + const term = filter.toLowerCase().trim() + return icons.filter( + (icon) => + icon.name.toLowerCase().includes(term) || + icon.reactName.toLowerCase().includes(term), + ) +} + /** * GET /api/{version}/icons * Returns list of all available icons with metadata. diff --git a/src/utils/icons/fetch.ts b/src/utils/icons/fetch.ts index beafe2f..7f13f2e 100644 --- a/src/utils/icons/fetch.ts +++ b/src/utils/icons/fetch.ts @@ -1,4 +1,4 @@ -import type { IconMetadata } from './reactIcons' +import type { IconMetadata } from './icons' export interface IconsIndex { icons: IconMetadata[] @@ -41,7 +41,7 @@ export async function fetchIconSvgs( version: string, setId: string, ): Promise | null> { - const iconsSvgsUrl = new URL(`/api/${version}/icons/${setId}.json`, url.origin) + const iconsSvgsUrl = new URL(`/api/${version}/icons/${setId}`, url.origin) const response = assetsFetch ? await assetsFetch(new Request(iconsSvgsUrl)) : await fetch(iconsSvgsUrl) diff --git a/src/utils/icons/icons.ts b/src/utils/icons/icons.ts new file mode 100644 index 0000000..c0b3069 --- /dev/null +++ b/src/utils/icons/icons.ts @@ -0,0 +1,7 @@ +export interface IconMetadata { + name: string + reactName: string + usage: string + } + + export const PF_ICONS_SET_ID = 'pf' \ No newline at end of file diff --git a/src/utils/icons/reactIcons.ts b/src/utils/icons/reactIcons.ts index 000518f..5f8e87c 100644 --- a/src/utils/icons/reactIcons.ts +++ b/src/utils/icons/reactIcons.ts @@ -2,17 +2,9 @@ * Utilities for working with @patternfly/react-icons. * Icons are loaded from @patternfly/react-icons/dist/static (SVG files). */ -import fs from 'fs' -import path from 'path' - -export interface IconMetadata { - name: string - reactName: string - usage: string -} - -const PF_ICONS_SET_ID = 'pf' - +import fs from 'node:fs' +import path from 'node:path' +import { IconMetadata, PF_ICONS_SET_ID } from './icons' /** Resolve path to @patternfly/react-icons/dist/static. Uses cwd so it works in dev and build. */ function getStaticIconsDir(): string { return path.join( @@ -70,24 +62,6 @@ export async function getAllIcons(): Promise { return icons } -/** - * Filter icons by search term (case-insensitive match on name or reactName) - */ -export function filterIcons( - icons: IconMetadata[], - filter: string, -): IconMetadata[] { - if (!filter || !filter.trim()) { - return icons - } - const term = filter.toLowerCase().trim() - return icons.filter( - (icon) => - icon.name.toLowerCase().includes(term) || - icon.reactName.toLowerCase().includes(term), - ) -} - /** * Get SVG markup for all PatternFly icons (single set). * Used at build time for prerendering. diff --git a/wrangler.jsonc b/wrangler.jsonc index f8e5f43..c86b9df 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -1,7 +1,7 @@ { "$schema": "node_modules/wrangler/config-schema.json", "name": "patternfly-docs-core", - "compatibility_date": "2025-06-17", + "compatibility_date": "2026-02-18", "compatibility_flags": ["nodejs_compat"], "observability": { "enabled": true