diff --git a/src/__tests__/pages/api/__tests__/[version]/[section]/[page]/props.test.ts b/src/__tests__/pages/api/__tests__/[version]/[section]/[page]/props.test.ts index 28cc3f7..687dc7e 100644 --- a/src/__tests__/pages/api/__tests__/[version]/[section]/[page]/props.test.ts +++ b/src/__tests__/pages/api/__tests__/[version]/[section]/[page]/props.test.ts @@ -1,43 +1,18 @@ import { GET } from '../../../../../../../pages/api/[version]/[section]/[page]/props' -import { getConfig } from '../../../../../../../../cli/getConfig' -import { sentenceCase, removeSubsection } from '../../../../../../../utils/case' +import { removeSubsection } from '../../../../../../../utils/case' /** - * Mock getConfig to return a test configuration + * Mock fetchProps to return props data */ -jest.mock('../../../../../../../../cli/getConfig', () => ({ - getConfig: jest.fn().mockResolvedValue({ - outputDir: '/mock/output/dir', - }), -})) - -/** - * Mock node:path join function - */ -const mockJoin = jest.fn((...paths: string[]) => paths.join('/')) -jest.mock('node:path', () => ({ - join: (...args: any[]) => mockJoin(...args), -})) - -/** - * Mock node:fs readFileSync function - */ -const mockReadFileSync = jest.fn() -jest.mock('node:fs', () => ({ - readFileSync: (...args: any[]) => mockReadFileSync(...args), +const mockFetchProps = jest.fn() +jest.mock('../../../../../../../utils/propsData/fetch', () => ({ + fetchProps: (...args: any[]) => mockFetchProps(...args), })) /** * Mock sentenceCase and removeSubsection utilities */ jest.mock('../../../../../../../utils/case', () => ({ - sentenceCase: jest.fn((id: string) => - // Simple mock: convert kebab-case to Sentence Case - id - .split('-') - .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) - .join(' ') - ), removeSubsection: jest.fn((page: string) => { // Simple mock: remove subsection prefix from page name if (page.includes('_')) { @@ -70,7 +45,7 @@ const mockData = { }, ], }, - 'Sample Data Row': { + SampleDataRow: { name: 'SampleDataRow', description: '', props: [ @@ -82,7 +57,7 @@ const mockData = { }, ], }, - 'Dashboard Wrapper': { + DashboardWrapper: { name: 'DashboardWrapper', description: '', props: [ @@ -93,7 +68,7 @@ const mockData = { }, ], }, - 'Keyboard Handler': { + KeyboardHandler: { name: 'KeyboardHandler', description: '', props: [ @@ -105,14 +80,16 @@ const mockData = { }, ], }, + EmptyComponent: { + name: 'EmptyComponent', + description: '', + props: [], + }, } beforeEach(() => { jest.clearAllMocks() - // Reset process.cwd mock - process.cwd = jest.fn(() => '/mock/workspace') - // Reset mockReadFileSync to return default mock data - mockReadFileSync.mockReturnValue(JSON.stringify(mockData)) + mockFetchProps.mockResolvedValue(mockData) }) it('returns props data for a valid page', async () => { @@ -129,10 +106,9 @@ it('returns props data for a valid page', async () => { expect(body).toHaveProperty('props') expect(body.name).toBe('Alert') expect(Array.isArray(body.props)).toBe(true) - expect(sentenceCase).toHaveBeenCalledWith('alert') }) -it('converts kebab-case page name to sentence case for lookup', async () => { +it('converts kebab-case page name to pascal case for lookup', async () => { const response = await GET({ params: { version: 'v6', section: 'components', page: 'sample-data-row' }, url: new URL('http://localhost:4321/api/v6/components/sample-data-row/props'), @@ -141,7 +117,6 @@ it('converts kebab-case page name to sentence case for lookup', async () => { expect(response.status).toBe(200) expect(body.name).toBe('SampleDataRow') - expect(sentenceCase).toHaveBeenCalledWith('sample-data-row') }) it('handles multi-word page names correctly', async () => { @@ -153,7 +128,6 @@ it('handles multi-word page names correctly', async () => { expect(response.status).toBe(200) expect(body.name).toBe('DashboardWrapper') - expect(sentenceCase).toHaveBeenCalledWith('dashboard-wrapper') }) it('returns 404 error when props data is not found', async () => { @@ -181,28 +155,8 @@ it('returns 400 error when page parameter is missing', async () => { expect(body.error).toContain('Page parameter is required') }) -it('returns 500 error when props.json file is not found', async () => { - mockReadFileSync.mockImplementation(() => { - const error = new Error('ENOENT: no such file or directory') - ; (error as any).code = 'ENOENT' - throw error - }) - - const response = await GET({ - params: { version: 'v6', section: 'components', page: 'alert' }, - url: new URL('http://localhost:4321/api/v6/components/alert/props'), - } as any) - const body = await response.json() - - expect(response.status).toBe(500) - expect(body).toHaveProperty('error') - expect(body.error).toBe('Props data not found') - expect(body).toHaveProperty('details') - expect(body.details).toContain('ENOENT') -}) - -it('returns 500 error when props.json contains invalid JSON', async () => { - mockReadFileSync.mockReturnValue('invalid json content') +it('returns 500 error when fetchProps fails', async () => { + mockFetchProps.mockRejectedValueOnce(new Error('Network error')) const response = await GET({ params: { version: 'v6', section: 'components', page: 'alert' }, @@ -212,14 +166,13 @@ it('returns 500 error when props.json contains invalid JSON', async () => { expect(response.status).toBe(500) expect(body).toHaveProperty('error') - expect(body.error).toBe('Props data not found') + expect(body.error).toBe('Failed to load props data') expect(body).toHaveProperty('details') + expect(body.details).toBe('Network error') }) -it('returns 500 error when file read throws an error', async () => { - mockReadFileSync.mockImplementation(() => { - throw new Error('Permission denied') - }) +it('returns 500 error when fetchProps throws a non-Error object', async () => { + mockFetchProps.mockRejectedValueOnce('String error') const response = await GET({ params: { version: 'v6', section: 'components', page: 'alert' }, @@ -229,56 +182,9 @@ it('returns 500 error when file read throws an error', async () => { expect(response.status).toBe(500) expect(body).toHaveProperty('error') - expect(body.error).toBe('Props data not found') + expect(body.error).toBe('Failed to load props data') expect(body).toHaveProperty('details') - expect(body.details).toContain('Permission denied') -}) - -it('uses default outputDir when config does not provide one', async () => { - jest.mocked(getConfig).mockResolvedValueOnce({ - content: [], - propsGlobs: [], - outputDir: '', - }) - - const response = await GET({ - params: { version: 'v6', section: 'components', page: 'alert' }, - url: new URL('http://localhost:4321/api/v6/components/alert/props'), - } as any) - const body = await response.json() - - expect(response.status).toBe(200) - expect(body).toHaveProperty('name') - expect(mockJoin).toHaveBeenCalledWith('/mock/workspace/dist', 'props.json') -}) - -it('uses custom outputDir from config when provided', async () => { - jest.mocked(getConfig).mockResolvedValueOnce({ - outputDir: '/custom/output/path', - content: [], - propsGlobs: [], - }) - - const response = await GET({ - params: { version: 'v6', section: 'components', page: 'alert' }, - url: new URL('http://localhost:4321/api/v6/components/alert/props'), - } as any) - const body = await response.json() - - expect(response.status).toBe(200) - expect(body).toHaveProperty('name') - // Verify that join was called with custom outputDir - expect(mockJoin).toHaveBeenCalledWith('/custom/output/path', 'props.json') -}) - -it('reads props.json from the correct file path', async () => { - await GET({ - params: { version: 'v6', section: 'components', page: 'alert' }, - url: new URL('http://localhost:4321/api/v6/components/alert/props'), - } as any) - - // Verify readFileSync was called with the correct path - expect(mockReadFileSync).toHaveBeenCalledWith('/mock/output/dir/props.json') + expect(body.details).toBe('String error') }) it('returns full props structure with all fields', async () => { @@ -328,15 +234,6 @@ it('handles props with required field', async () => { }) it('handles components with empty props array', async () => { - const emptyPropsData = { - 'Empty Component': { - name: 'EmptyComponent', - description: '', - props: [], - }, - } - mockReadFileSync.mockReturnValueOnce(JSON.stringify(emptyPropsData)) - const response = await GET({ params: { version: 'v6', section: 'components', page: 'empty-component' }, url: new URL('http://localhost:4321/api/v6/components/empty-component/props'), @@ -350,8 +247,6 @@ it('handles components with empty props array', async () => { }) it('handles request when tab is in URL path but not in params', async () => { - // Note: props.ts route is at [page] level, so tab parameter is not available - // This test verifies the route works correctly with just page parameter const response = await GET({ params: { version: 'v6', section: 'components', page: 'alert' }, url: new URL('http://localhost:4321/api/v6/components/alert/react/props'), @@ -364,8 +259,7 @@ it('handles request when tab is in URL path but not in params', async () => { }) it('removes subsection from page name before looking up props', async () => { - // Add test data for checkbox component - const dataWithSubsection = { + mockFetchProps.mockResolvedValueOnce({ ...mockData, Checkbox: { name: 'Checkbox', @@ -378,8 +272,7 @@ it('removes subsection from page name before looking up props', async () => { }, ], }, - } - mockReadFileSync.mockReturnValueOnce(JSON.stringify(dataWithSubsection)) + }) const response = await GET({ params: { version: 'v6', section: 'components', page: 'forms_checkbox' }, @@ -390,5 +283,4 @@ it('removes subsection from page name before looking up props', async () => { expect(response.status).toBe(200) expect(body.name).toBe('Checkbox') expect(removeSubsection).toHaveBeenCalledWith('forms_checkbox') - expect(sentenceCase).toHaveBeenCalledWith('checkbox') }) diff --git a/src/__tests__/pages/api/__tests__/[version]/[section]/names.test.ts b/src/__tests__/pages/api/__tests__/[version]/[section]/names.test.ts index 23edfde..332685b 100644 --- a/src/__tests__/pages/api/__tests__/[version]/[section]/names.test.ts +++ b/src/__tests__/pages/api/__tests__/[version]/[section]/names.test.ts @@ -1,29 +1,11 @@ import { GET } from '../../../../../../pages/api/[version]/[section]/names' -import { getConfig } from '../../../../../../../cli/getConfig' /** - * Mock getConfig to return a test configuration + * Mock fetchProps to return props data */ -jest.mock('../../../../../../../cli/getConfig', () => ({ - getConfig: jest.fn().mockResolvedValue({ - outputDir: '/mock/output/dir', - }), -})) - -/** - * Mock node:path join function - */ -const mockJoin = jest.fn((...paths: string[]) => paths.join('/')) -jest.mock('node:path', () => ({ - join: (...args: any[]) => mockJoin(...args), -})) - -/** - * Mock node:fs readFileSync function - */ -const mockReadFileSync = jest.fn() -jest.mock('node:fs', () => ({ - readFileSync: (...args: any[]) => mockReadFileSync(...args), +const mockFetchProps = jest.fn() +jest.mock('../../../../../../utils/propsData/fetch', () => ({ + fetchProps: (...args: any[]) => mockFetchProps(...args), })) const mockData = { @@ -86,10 +68,7 @@ const mockData = { beforeEach(() => { jest.clearAllMocks() - // Reset process.cwd mock - process.cwd = jest.fn(() => '/mock/workspace') - // Reset mockReadFileSync to return default mock data - mockReadFileSync.mockReturnValue(JSON.stringify(mockData)) + mockFetchProps.mockResolvedValue(mockData) }) it('returns filtered component names from props.json data', async () => { @@ -110,7 +89,7 @@ it('returns filtered component names from props.json data', async () => { }) it('filters out all keys containing "Props" case-insensitively', async () => { - const testData = { + mockFetchProps.mockResolvedValueOnce({ Alert: {}, Button: {}, AlertProps: {}, @@ -118,9 +97,7 @@ it('filters out all keys containing "Props" case-insensitively', async () => { alertprops: {}, ComponentProps: {}, SomeComponentProps: {}, - } - - mockReadFileSync.mockReturnValue(JSON.stringify(testData)) + }) const response = await GET({ params: { version: 'v6', section: 'components' }, @@ -138,13 +115,11 @@ it('filters out all keys containing "Props" case-insensitively', async () => { }) it('returns empty array when props.json has no valid component names', async () => { - const testData = { + mockFetchProps.mockResolvedValueOnce({ AlertProps: {}, ButtonProps: {}, ComponentProps: {}, - } - - mockReadFileSync.mockReturnValue(JSON.stringify(testData)) + }) const response = await GET({ params: { version: 'v6', section: 'components' }, @@ -158,7 +133,7 @@ it('returns empty array when props.json has no valid component names', async () }) it('returns empty array when props.json is empty', async () => { - mockReadFileSync.mockReturnValue(JSON.stringify({})) + mockFetchProps.mockResolvedValueOnce({}) const response = await GET({ params: { version: 'v6', section: 'components' }, @@ -171,12 +146,8 @@ it('returns empty array when props.json is empty', async () => { expect(body).toEqual([]) }) -it('returns 500 error when props.json file is not found', async () => { - mockReadFileSync.mockImplementation(() => { - const error = new Error('ENOENT: no such file or directory') - ; (error as any).code = 'ENOENT' - throw error - }) +it('returns 500 error when fetchProps fails', async () => { + mockFetchProps.mockRejectedValueOnce(new Error('Network error')) const response = await GET({ params: { version: 'v6', section: 'components' }, @@ -188,11 +159,11 @@ it('returns 500 error when props.json file is not found', async () => { expect(body).toHaveProperty('error') expect(body.error).toBe('Component names data not found') expect(body).toHaveProperty('details') - expect(body.details).toContain('ENOENT') + expect(body.details).toBe('Network error') }) -it('returns 500 error when props.json contains invalid JSON', async () => { - mockReadFileSync.mockReturnValue('invalid json content') +it('returns 500 error when fetchProps throws a non-Error object', async () => { + mockFetchProps.mockRejectedValueOnce('String error') const response = await GET({ params: { version: 'v6', section: 'components' }, @@ -204,67 +175,5 @@ it('returns 500 error when props.json contains invalid JSON', async () => { expect(body).toHaveProperty('error') expect(body.error).toBe('Component names data not found') expect(body).toHaveProperty('details') -}) - -it('returns 500 error when file read throws an error', async () => { - mockReadFileSync.mockImplementation(() => { - throw new Error('Permission denied') - }) - - const response = await GET({ - params: { version: 'v6', section: 'components' }, - url: new URL('http://localhost:4321/api/v6/components/names'), - } as any) - const body = await response.json() - - expect(response.status).toBe(500) - expect(body).toHaveProperty('error') - expect(body.error).toBe('Component names data not found') - expect(body).toHaveProperty('details') - expect(body.details).toContain('Permission denied') -}) - -it('uses default outputDir when config does not provide one', async () => { - jest.mocked(getConfig).mockResolvedValueOnce({ - content: [], - propsGlobs: [], - outputDir: '', - }) - - const response = await GET({ - params: { version: 'v6', section: 'components' }, - url: new URL('http://localhost:4321/api/v6/components/names'), - } as any) - const body = await response.json() - - expect(response.status).toBe(200) - expect(Array.isArray(body)).toBe(true) - expect(mockJoin).toHaveBeenCalledWith('/mock/workspace/dist', 'props.json') -}) - -it('uses custom outputDir from config when provided', async () => { - jest.mocked(getConfig).mockResolvedValueOnce({ - outputDir: '/custom/output/path', - content: [], - propsGlobs: [], - }) - - const response = await GET({ - params: { version: 'v6', section: 'components' }, - url: new URL('http://localhost:4321/api/v6/components/names'), - } as any) - const body = await response.json() - - expect(response.status).toBe(200) - expect(Array.isArray(body)).toBe(true) - expect(mockJoin).toHaveBeenCalledWith('/custom/output/path', 'props.json') -}) - -it('reads props.json from the correct file path', async () => { - await GET({ - params: { version: 'v6', section: 'components' }, - url: new URL('http://localhost:4321/api/v6/components/names'), - } as any) - - expect(mockReadFileSync).toHaveBeenCalledWith('/mock/output/dir/props.json') + expect(body.details).toBe('String error') }) diff --git a/src/pages/api/[version]/[section]/[page]/props.ts b/src/pages/api/[version]/[section]/[page]/props.ts index 15333be..e4fac86 100644 --- a/src/pages/api/[version]/[section]/[page]/props.ts +++ b/src/pages/api/[version]/[section]/[page]/props.ts @@ -1,13 +1,13 @@ import type { APIRoute } from 'astro' +import { pascalCase } from 'change-case' + import { createJsonResponse } from '../../../../../utils/apiHelpers' -import { getConfig } from '../../../../../../cli/getConfig' -import { join } from 'node:path' -import { readFileSync } from 'node:fs' -import { sentenceCase, removeSubsection } from '../../../../../utils/case' +import { fetchProps } from '../../../../../utils/propsData/fetch' +import { removeSubsection } from '../../../../../utils/case' export const prerender = false -export const GET: APIRoute = async ({ params }) => { +export const GET: APIRoute = async ({ params, url }) => { const { page } = params if (!page) { @@ -18,14 +18,8 @@ export const GET: APIRoute = async ({ params }) => { } try { - const config = await getConfig(`${process.cwd()}/pf-docs.config.mjs`) - const outputDir = config?.outputDir || join(process.cwd(), 'dist') - - const propsFilePath = join(outputDir, 'props.json') - const propsDataFile = readFileSync(propsFilePath) - const props = JSON.parse(propsDataFile.toString()) - - const propsData = props[sentenceCase(removeSubsection(page))] + const props = await fetchProps(url) + const propsData = props[pascalCase(removeSubsection(page))] if (propsData === undefined) { return createJsonResponse( @@ -39,11 +33,8 @@ export const GET: APIRoute = async ({ params }) => { } catch (error) { const details = error instanceof Error ? error.message : String(error) return createJsonResponse( - { error: 'Props data not found', details }, + { error: 'Failed to load props data', details }, 500, ) } } - - - diff --git a/src/pages/api/[version]/[section]/names.ts b/src/pages/api/[version]/[section]/names.ts index af41ded..5354fd9 100644 --- a/src/pages/api/[version]/[section]/names.ts +++ b/src/pages/api/[version]/[section]/names.ts @@ -1,19 +1,12 @@ import type { APIRoute } from 'astro' import { createJsonResponse } from '../../../../utils/apiHelpers' -import { getConfig } from '../../../../../cli/getConfig' -import { join } from 'node:path' -import { readFileSync } from 'node:fs' +import { fetchProps } from '../../../../utils/propsData/fetch' export const prerender = false -export const GET: APIRoute = async ({ }) => { +export const GET: APIRoute = async ({ url }) => { try { - const config = await getConfig(`${process.cwd()}/pf-docs.config.mjs`) - const outputDir = config?.outputDir || join(process.cwd(), 'dist') - - const propsFilePath = join(outputDir, 'props.json') - const propsDataFile = readFileSync(propsFilePath) - const props = JSON.parse(propsDataFile.toString()) + const props = await fetchProps(url) const propsKey = new RegExp("Props", 'i'); // ignore ComponentProps objects const names = Object.keys(props).filter(name => !propsKey.test(name)) @@ -27,6 +20,3 @@ export const GET: APIRoute = async ({ }) => { ) } } - - - diff --git a/src/pages/props.json.ts b/src/pages/props.json.ts new file mode 100644 index 0000000..77d21a0 --- /dev/null +++ b/src/pages/props.json.ts @@ -0,0 +1,25 @@ +import type { APIRoute } from 'astro' +import { readFile } from 'fs/promises' +import { join } from 'path' +import { getOutputDir } from '../utils/getOutputDir' + +// Prerender at build time so this doesn't run in the Cloudflare Worker +export const prerender = true + +export const GET: APIRoute = async () => { + try { + const outputDir = await getOutputDir() + const propsFilePath = join(outputDir, 'props.json') + const propsData = await readFile(propsFilePath, 'utf-8') + + return new Response(propsData, { + status: 200, + headers: { + 'Content-Type': 'application/json', + }, + }) + } catch (error) { + const details = error instanceof Error ? error.message : String(error) + throw new Error(`Failed to prerender /props.json: ${details}`) + } +} diff --git a/src/pages/props.ts b/src/pages/props.ts index 8b8373f..d3b29c9 100644 --- a/src/pages/props.ts +++ b/src/pages/props.ts @@ -1,21 +1,31 @@ -import { readFileSync } from 'node:fs' -import { join } from 'node:path' -import { getConfig } from '../../cli/getConfig' +import { fetchProps } from '../utils/propsData/fetch' +import { createJsonResponse } from '../utils/apiHelpers' export const prerender = false export async function GET({ request }: { request: Request }) { - const config = await getConfig(`${process.cwd()}/pf-docs.config.mjs`) - const outputDir = config?.outputDir || join(process.cwd(), 'dist') + const url = new URL(request.url) - const propsFilePath = join(outputDir, 'props.json') - const propsDataFile = readFileSync(propsFilePath) - const props = JSON.parse(propsDataFile.toString()) + const components = url.searchParams.get('components') + if (!components) { + return createJsonResponse( + { error: 'components query parameter is required' }, + 400, + ) + } - const queryParams = new URL(request.url).searchParams - const components = queryParams.get('components') - const componentsArray = components?.split(',') - const propsData = componentsArray?.map((component) => props[component]) + try { + const props = await fetchProps(url) + const propsData = components + .split(',') + .map((component) => props[component.trim()]) - return new Response(JSON.stringify(propsData)) + return createJsonResponse(propsData) + } catch (error) { + const details = error instanceof Error ? error.message : String(error) + return createJsonResponse( + { error: 'Failed to load props data', details }, + 500, + ) + } } diff --git a/src/utils/propsData/fetch.ts b/src/utils/propsData/fetch.ts new file mode 100644 index 0000000..451eec7 --- /dev/null +++ b/src/utils/propsData/fetch.ts @@ -0,0 +1,17 @@ +/** + * Fetches the props data from the server as a static asset + * Used by API routes at runtime instead of reading from the filesystem + * + * @param url - The URL object from the API route context + * @returns Promise resolving to the props data structure + */ +export async function fetchProps(url: URL): Promise> { + const propsUrl = new URL('/props.json', url.origin) + const response = await fetch(propsUrl) + + if (!response.ok) { + throw new Error(`Failed to load props data: ${response.status} ${response.statusText}`) + } + + return await response.json() +}