Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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<typeof readFile>
const mockAccess = access as jest.MockedFunction<typeof access>

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<string, any[]> = {
'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 = () => <Alert />')

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 = () => <Alert />')

// 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')
})
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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')
Expand Down
27 changes: 15 additions & 12 deletions src/utils/apiRoutes/collections.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { getDefaultTabForApi } from '../packageUtils'

export type EnrichedContentEntry = {
filePath: string
base?: string
data: {
tab: string
[key: string]: any
Expand All @@ -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<EnrichedContentEntry[]> {
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),
},
}))
})
}
6 changes: 3 additions & 3 deletions src/utils/apiRoutes/contentMatching.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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))

Expand All @@ -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 }
}
Loading