diff --git a/src/__tests__/pages/api/__tests__/[version]/[section]/[page]/[tab]/examples/[example].test.ts b/src/__tests__/pages/api/__tests__/[version]/[section]/[page]/[tab]/examples/[example].test.ts new file mode 100644 index 0000000..05f68bd --- /dev/null +++ b/src/__tests__/pages/api/__tests__/[version]/[section]/[page]/[tab]/examples/[example].test.ts @@ -0,0 +1,197 @@ +import { GET } from '../../../../../../../../../pages/api/[version]/[section]/[page]/[tab]/examples/[example]' +import { access, readFile } from 'fs/promises' + +jest.mock('fs/promises') +const mockReadFile = readFile as jest.MockedFunction +const mockAccess = access as jest.MockedFunction + +jest.mock('../../../../../../../../../content', () => ({ + content: [ + { + name: 'react-component-docs', + base: '/mock/monorepo/packages/react-core', + pattern: '**/*.md', + version: 'v6', + }, + ], +})) + +jest.mock('astro:content', () => ({ + getCollection: jest.fn((collectionName: string) => { + const mockData: Record = { + 'react-component-docs': [ + { + id: 'components/alert/react', + slug: 'components/alert/react', + body: '', + filePath: 'patternfly-docs/components/Alert/examples/Alert.md', + data: { + id: 'Alert', + title: 'Alert', + section: 'components', + tab: 'react', + }, + collection: 'react-component-docs', + }, + ], + } + return Promise.resolve(mockData[collectionName] || []) + }), +})) + +jest.mock('../../../../../../../../../utils', () => ({ + kebabCase: jest.fn((id: string) => { + if (!id) { return '' } + return id + .replace(/([a-z])([A-Z])/g, '$1-$2') + .replace(/[\s_]+/g, '-') + .toLowerCase() + }), + getDefaultTabForApi: jest.fn((filePath?: string) => { + if (!filePath) { return 'react' } + if (filePath.includes('react')) { return 'react' } + return 'react' + }), + addDemosOrDeprecated: jest.fn((tabName: string, filePath?: string) => { + if (!filePath || !tabName) { return '' } + return tabName + }), + addSubsection: jest.fn((page: string, subsection?: string) => { + if (!subsection) { return page } + return `${subsection.toLowerCase()}_${page}` + }), +})) + +jest.mock('../../../../../../../../../utils/apiIndex/generate', () => ({ + generateAndWriteApiIndex: jest.fn().mockResolvedValue({ + versions: ['v6'], + sections: { v6: ['components'] }, + pages: { 'v6::components': ['alert'] }, + tabs: { 'v6::components::alert': ['react'] }, + examples: { + 'v6::components::alert::react': [ + { exampleName: 'AlertBasic' }, + ], + }, + }), +})) + +beforeEach(() => { + jest.clearAllMocks() +}) + +const mdxContent = ` +import AlertBasic from './AlertBasic.tsx?raw' +import AlertCustomIcon from './AlertCustomIcon.tsx?raw' +` + +it('resolves example files relative to base in monorepo setups', async () => { + // Simulate monorepo: raw filePath doesn't exist at CWD, so access rejects + mockAccess.mockRejectedValueOnce(new Error('ENOENT')) + + // First call reads the content entry file, second reads the example file + mockReadFile + .mockResolvedValueOnce(mdxContent) + .mockResolvedValueOnce('const AlertBasic = () => ') + + const response = await GET({ + params: { + version: 'v6', + section: 'components', + page: 'alert', + tab: 'react', + example: 'AlertBasic', + }, + } as any) + + expect(response.status).toBe(200) + const text = await response.text() + expect(text).toBe('const AlertBasic = () => ') + + // Content entry file should be resolved with base + expect(mockReadFile).toHaveBeenCalledWith( + '/mock/monorepo/packages/react-core/patternfly-docs/components/Alert/examples/Alert.md', + 'utf8' + ) + + // Example file should be resolved with base + content entry dir + expect(mockReadFile).toHaveBeenCalledWith( + '/mock/monorepo/packages/react-core/patternfly-docs/components/Alert/examples/AlertBasic.tsx', + 'utf8' + ) +}) + +it('returns 404 when example is not found in imports', async () => { + mockAccess.mockRejectedValueOnce(new Error('ENOENT')) + mockReadFile.mockResolvedValueOnce(mdxContent) + + const response = await GET({ + params: { + version: 'v6', + section: 'components', + page: 'alert', + tab: 'react', + example: 'NonExistent', + }, + } as any) + + expect(response.status).toBe(404) + const body = await response.json() + expect(body.error).toContain('NonExistent') +}) + +it('returns 404 when example file does not exist on disk', async () => { + mockAccess.mockRejectedValueOnce(new Error('ENOENT')) + + const enoentError = new Error('ENOENT: no such file or directory') as NodeJS.ErrnoException + enoentError.code = 'ENOENT' + + mockReadFile + .mockResolvedValueOnce(mdxContent as any) + .mockRejectedValueOnce(enoentError) + + const response = await GET({ + params: { + version: 'v6', + section: 'components', + page: 'alert', + tab: 'react', + example: 'AlertBasic', + }, + } as any) + + const body = await response.json() + expect(response.status).toBe(404) + expect(body.error).toContain('Example file not found') +}) + +it('returns 400 when required parameters are missing', async () => { + const response = await GET({ + params: { + version: 'v6', + section: 'components', + page: 'alert', + tab: 'react', + }, + } as any) + + expect(response.status).toBe(400) + const body = await response.json() + expect(body.error).toContain('required') +}) + +it('returns 404 when content entry is not found', async () => { + const response = await GET({ + params: { + version: 'v6', + section: 'components', + page: 'nonexistent', + tab: 'react', + example: 'AlertBasic', + }, + } as any) + + const body = await response.json() + expect(response.status).toBe(404) + expect(body.error).toContain('Content entry not found') +}) diff --git a/src/pages/api/[version]/[section]/[page]/[tab]/examples/[example].ts b/src/pages/api/[version]/[section]/[page]/[tab]/examples/[example].ts index df783ef..a3d36b9 100644 --- a/src/pages/api/[version]/[section]/[page]/[tab]/examples/[example].ts +++ b/src/pages/api/[version]/[section]/[page]/[tab]/examples/[example].ts @@ -1,7 +1,7 @@ import type { APIRoute, GetStaticPaths } from 'astro' -import { readFile } from 'fs/promises' -import { resolve } from 'path' +import { access, readFile } from 'fs/promises' +import { isAbsolute, resolve } from 'path' import { createJsonResponse, createTextResponse } from '../../../../../../../utils/apiHelpers' import { generateAndWriteApiIndex } from '../../../../../../../utils/apiIndex/generate' import { getEnrichedCollections } from '../../../../../../../utils/apiRoutes/collections' @@ -66,23 +66,38 @@ export const GET: APIRoute = async ({ params }) => { try { const collections = await getEnrichedCollections(version) - const contentEntryFilePath = findContentEntryFilePath(collections, { + const contentEntryMatch = findContentEntryFilePath(collections, { section, page, tab }) - if (!contentEntryFilePath) { + if (!contentEntryMatch) { return createJsonResponse( { error: `Content entry not found for ${version}/${section}/${page}/${tab}` }, 404 ) } + const { filePath: contentEntryFilePath, base } = contentEntryMatch + + // Resolve the content entry file path. + // In non-monorepo setups, filePath is relative to CWD and resolves directly. + // In monorepo setups, filePath may be relative to `base` instead of CWD. + // We try the original path first, then fall back to resolve(base, filePath). + let resolvedContentPath = contentEntryFilePath + if (base && !isAbsolute(contentEntryFilePath)) { + try { + await access(contentEntryFilePath) + } catch { + resolvedContentPath = resolve(base, contentEntryFilePath) + } + } + // Read content entry file to extract imports let contentEntryFileContent: string try { - contentEntryFileContent = await readFile(contentEntryFilePath, 'utf8') + contentEntryFileContent = await readFile(resolvedContentPath, 'utf8') } catch (error) { const details = error instanceof Error ? error.message : String(error) return createJsonResponse( @@ -110,8 +125,8 @@ export const GET: APIRoute = async ({ params }) => { // Strip query parameters (like ?raw) from the file path before reading const cleanFilePath = relativeExampleFilePath.split('?')[0] - // Read example file - const absoluteExampleFilePath = resolve(contentEntryFilePath, '../', cleanFilePath) + // Read example file, resolving relative to the content entry file's directory + const absoluteExampleFilePath = resolve(resolvedContentPath, '../', cleanFilePath) let exampleFileContent: string try { exampleFileContent = await readFile(absoluteExampleFilePath, 'utf8') diff --git a/src/utils/apiRoutes/collections.ts b/src/utils/apiRoutes/collections.ts index d0f6e94..0c82275 100644 --- a/src/utils/apiRoutes/collections.ts +++ b/src/utils/apiRoutes/collections.ts @@ -5,6 +5,7 @@ import { getDefaultTabForApi } from '../packageUtils' export type EnrichedContentEntry = { filePath: string + base?: string data: { tab: string [key: string]: any @@ -20,20 +21,22 @@ export type EnrichedContentEntry = { * @returns Promise resolving to array of collection entries with enriched metadata */ export async function getEnrichedCollections(version: string): Promise { - const collectionsToFetch = content - .filter((entry) => entry.version === version) - .map((entry) => entry.name as CollectionKey) + const contentEntries = content.filter((entry) => entry.version === version) const collections = await Promise.all( - collectionsToFetch.map((name) => getCollection(name)) + contentEntries.map((entry) => getCollection(entry.name as CollectionKey)) ) - return collections.flat().map(({ data, filePath, ...rest }) => ({ - filePath, - ...rest, - data: { - ...data, - tab: data.tab || data.source || getDefaultTabForApi(filePath), - }, - })) + return collections.flatMap((collectionEntries, index) => { + const base = contentEntries[index].base + return collectionEntries.map(({ data, filePath = '', ...rest }) => ({ + filePath, + base, + ...rest, + data: { + ...data, + tab: data.tab || data.source || getDefaultTabForApi(filePath), + }, + })) + }) } diff --git a/src/utils/apiRoutes/contentMatching.ts b/src/utils/apiRoutes/contentMatching.ts index 4698171..b40fda7 100644 --- a/src/utils/apiRoutes/contentMatching.ts +++ b/src/utils/apiRoutes/contentMatching.ts @@ -66,12 +66,12 @@ export function findContentEntry( * @param entries - Array of enriched content entries to search * @param params - Parameters to match against (section, page, tab) * - page may be underscore-separated for subsection pages (e.g., "forms_checkbox") - * @returns The file path, or null if not found + * @returns Object with filePath and optional base, or null if not found */ export function findContentEntryFilePath( entries: EnrichedContentEntry[], params: ContentMatchParams -): string | null { +): { filePath: string; base?: string } | null { // Find all matching entries using shared matching logic const matchingEntries = entries.filter((entry) => matchesParams(entry, params)) @@ -83,5 +83,5 @@ export function findContentEntryFilePath( const mdxEntry = matchingEntries.find((entry) => entry.filePath.endsWith('.mdx')) const selectedEntry = mdxEntry || matchingEntries[0] - return selectedEntry.filePath + return { filePath: selectedEntry.filePath, base: selectedEntry.base } }