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/package-lock.json b/package-lock.json index eb6c533..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", @@ -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" }, @@ -4084,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", @@ -4103,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", @@ -4121,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", @@ -4140,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", @@ -4159,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==", @@ -4169,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", @@ -4193,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", @@ -4214,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", @@ -21902,6 +21976,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..9b695c5 100644 --- a/package.json +++ b/package.json @@ -55,14 +55,14 @@ "@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", - "@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", - "@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/__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..c9c2840 --- /dev/null +++ b/src/__tests__/pages/api/__tests__/[version]/icons/[iconName].test.ts @@ -0,0 +1,176 @@ +import { GET } from '../../../../../../pages/api/[version]/icons/[iconName]' + +const mockApiIndex = { + versions: ['v5', 'v6'], + sections: {}, + pages: {}, + tabs: {}, +} + +const mockSvg = '' + +const mockIconSvgs: Record> = { + pf: { CircleIcon: mockSvg }, +} + +const mockIconsIndex = { + icons: [ + { name: 'circle', reactName: 'CircleIcon', usage: '' }, + ], +} + +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(/\/api\/[^/]+\/icons\/([^/]+)\.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({ + ok: true, + json: () => Promise.resolve(mockApiIndex), + } as Response) + }) as typeof fetch +} + +it('returns SVG markup for valid icon', async () => { + global.fetch = createFetchMock() + + const response = await GET({ + params: { version: 'v6', iconName: 'CircleIcon' }, + url: new URL('http://localhost:4321/api/v6/icons/CircleIcon'), + } 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 = createFetchMock() + + const response = await GET({ + 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('NonExistentIcon') + expect(body.error).toContain('not found') + + jest.restoreAllMocks() +}) + +it('returns 404 when icon name is not in index', async () => { + global.fetch = createFetchMock() + + 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(404) + expect(body).toHaveProperty('error') + expect(body.error).toContain('invalid') + expect(body.error).toContain('not found') + + jest.restoreAllMocks() +}) + +it('returns 400 when icon name parameter is missing', async () => { + global.fetch = createFetchMock() + + 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 = createFetchMock() + + const response = await GET({ + params: { version: 'v99', iconName: 'CircleIcon' }, + url: new URL('http://localhost:4321/api/v99/icons/CircleIcon'), + } 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 = createFetchMock() + + const response = await GET({ + params: { iconName: 'CircleIcon' }, + url: new URL('http://localhost:4321/api/icons/CircleIcon'), + } 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((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: 'CircleIcon' }, + url: new URL('http://localhost:4321/api/v6/icons/CircleIcon'), + } 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..8a73faa --- /dev/null +++ b/src/__tests__/pages/api/__tests__/[version]/icons/index.test.ts @@ -0,0 +1,184 @@ +import { GET } from '../../../../../../pages/api/[version]/icons/index' + +const mockApiIndex = { + versions: ['v5', 'v6'], + sections: {}, + pages: {}, + tabs: {}, +} + +const mockIcons = [ + { + name: 'circle', + reactName: 'CircleIcon', + usage: "import { CircleIcon } from '@patternfly/react-icons'", + }, + { + name: 'home', + reactName: 'HomeIcon', + usage: "import { HomeIcon } from '@patternfly/react-icons'", + }, + { + name: 'circle-outline', + reactName: 'CircleOutlineIcon', + usage: "import { CircleOutlineIcon } from '@patternfly/react-icons'", + }, +] + +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', () => ({ + 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 = createFetchMock() + + 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('usage') + + jest.restoreAllMocks() +}) + +it('filters icons when filter parameter is provided', async () => { + global.fetch = createFetchMock() + + 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'))).toBe(true) + + jest.restoreAllMocks() +}) + +it('filter is case-insensitive', async () => { + global.fetch = createFetchMock() + + 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 = createFetchMock() + + 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 = createFetchMock() + + 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 = createFetchMock() + + 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 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 typeof fetch + + 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/[version]/icons/[iconName].ts b/src/pages/api/[version]/icons/[iconName].ts new file mode 100644 index 0000000..cc16c45 --- /dev/null +++ b/src/pages/api/[version]/icons/[iconName].ts @@ -0,0 +1,62 @@ +import type { APIRoute } from 'astro' +import { + createJsonResponse, + createSvgResponse, +} from '../../../../utils/apiHelpers' +import { fetchApiIndex } from '../../../../utils/apiIndex/fetch' +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: React component name (e.g., FaCircle, MdHome) + */ +export const GET: APIRoute = async ({ params, url }) => { + const { version, iconName: reactName } = 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 (!reactName) { + return createJsonResponse( + { error: 'Icon name parameter is required' }, + 400, + ) + } + + const icons = await fetchIconsIndex(url) + const icon = icons.find((i) => i.reactName === reactName) + if (!icon) { + return createJsonResponse( + { error: `Icon '${reactName}' not found` }, + 404, + ) + } + + const svgs = await fetchIconSvgs(url, version, 'pf', assetsFetch) + const svg = svgs?.[reactName] ?? null + + if (!svg) { + return createJsonResponse( + { error: `Icon '${reactName}' not found` }, + 404, + ) + } + + return createSvgResponse(svg) +} diff --git a/src/pages/api/[version]/icons/[iconSet].ts b/src/pages/api/[version]/icons/[iconSet].ts new file mode 100644 index 0000000..3a2e186 --- /dev/null +++ b/src/pages/api/[version]/icons/[iconSet].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/v6/icons/pf). + */ +export const prerender = true + +export const getStaticPaths: GetStaticPaths = async () => { + const versions = await getVersionsFromIndexFile() + return versions.flatMap((version) => [ + { params: { version, iconSet: 'pf' } }, + ]) +} + +export const GET: APIRoute = async ({ params }) => { + const { version, iconSet } = params + if (!version) { + return createJsonResponse( + { error: 'Version parameter is required' }, + 400, + ) + } + if (!iconSet) { + return createJsonResponse({ error: 'Icon set is required' }, 400) + } + + try { + const svgs = await getIconSvgsForSet(iconSet) + 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/api/[version]/icons/index.ts b/src/pages/api/[version]/icons/index.ts new file mode 100644 index 0000000..85be202 --- /dev/null +++ b/src/pages/api/[version]/icons/index.ts @@ -0,0 +1,66 @@ +import type { APIRoute } from 'astro' +import { createJsonResponse } from '../../../../utils/apiHelpers' +import { fetchApiIndex } from '../../../../utils/apiIndex/fetch' +import { fetchIconsIndex } from '../../../../utils/icons/fetch' +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. + * + * GET /api/{version}/icons?filter=circle + * Returns filtered list of icons matching the filter term (case-insensitive). + */ +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 fetchIconsIndex(url) + 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..db0d70e 100644 --- a/src/pages/api/index.ts +++ b/src/pages/api/index.ts @@ -367,6 +367,70 @@ export const GET: APIRoute = async () => ], }, }, + { + path: '/api/{version}/icons', + method: 'GET', + description: 'List all available icons with metadata from @patternfly/react-icons', + parameters: [ + { + name: 'version', + in: 'path', + required: true, + type: 'string', + example: 'v6', + }, + { + 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, usage', + example: { + icons: [ + { + name: 'circle', + reactName: 'CircleIcon', + usage: "import { CircleIcon } from '@patternfly/react-icons'", + }, + ], + total: 1, + filter: 'circle', + }, + }, + }, + { + 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', + required: true, + type: 'string', + description: 'Icon identifier: React component name (e.g., FaCircle, MdHome)', + example: '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..68a33cd 100644 --- a/src/pages/api/openapi.json.ts +++ b/src/pages/api/openapi.json.ts @@ -104,6 +104,99 @@ export const GET: APIRoute = async ({ url }) => { }, }, }, + '/{version}/icons': { + get: { + summary: 'List available icons', + description: + '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: [ + { + name: 'version', + in: 'path', + required: true, + schema: { type: 'string' }, + example: 'v6', + }, + { + 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: 'CircleIcon' }, + usage: { + type: 'string', + example: "import { CircleIcon } from '@patternfly/react-icons'", + }, + }, + }, + }, + total: { type: 'integer' }, + filter: { type: 'string' }, + }, + }, + }, + }, + }, + }, + }, + }, + '/{version}/icons/{icon-name}': { + get: { + summary: 'Get icon SVG markup', + description: + 'Returns actual SVG markup for the icon. Icon name: React component name (e.g., FaCircle, MdHome)', + operationId: 'getIconSvg', + parameters: [ + { + name: 'version', + in: 'path', + required: true, + schema: { type: 'string' }, + example: 'v6', + }, + { + name: 'icon-name', + in: 'path', + required: true, + schema: { type: 'string' }, + description: 'Icon identifier: React component name', + example: '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/pages/iconsIndex.json.ts b/src/pages/iconsIndex.json.ts new file mode 100644 index 0000000..b10be0f --- /dev/null +++ b/src/pages/iconsIndex.json.ts @@ -0,0 +1,30 @@ +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() reads from @patternfly/react-icons/dist/static (Node fs). + */ +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/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/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 new file mode 100644 index 0000000..7f13f2e --- /dev/null +++ b/src/utils/icons/fetch.ts @@ -0,0 +1,54 @@ +import type { IconMetadata } from './icons' + +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 +} + +/** + * 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 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(`/api/${version}/icons/${setId}`, url.origin) + const response = assetsFetch + ? await assetsFetch(new Request(iconsSvgsUrl)) + : await fetch(iconsSvgsUrl) + + if (!response.ok) { + return null + } + + return (await response.json()) as Record +} 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 new file mode 100644 index 0000000..5f8e87c --- /dev/null +++ b/src/utils/icons/reactIcons.ts @@ -0,0 +1,141 @@ +/** + * Utilities for working with @patternfly/react-icons. + * Icons are loaded from @patternfly/react-icons/dist/static (SVG files). + */ +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( + process.cwd(), + '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 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() +} + +/** + * Get all icons from @patternfly/react-icons/dist/static with metadata. + * Shape: { name, reactName, usage } + */ +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 file of svgFiles) { + const name = file.replace(/\.svg$/, '') + const reactName = kebabToReactName(name) + icons.push({ + name, + reactName, + usage: `import { ${reactName} } from '@patternfly/react-icons'`, + }) + } + + return icons +} + +/** + * 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 (setId !== PF_ICONS_SET_ID) { + return {} + } + + 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 - Must be "pf" + * @param iconName - React component name (e.g., "AccessibleIconIcon") + */ +export async function getIconSvg( + setId: string, + iconName: string, +): Promise { + if (setId !== PF_ICONS_SET_ID) { + return null + } + + const kebab = reactNameToKebab(iconName) + const fileName = `${kebab}.svg` + const staticDir = getStaticIconsDir() + const filePath = path.join(staticDir, fileName) + + 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 { + 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 } +} 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