Skip to content

Commit c5bcff8

Browse files
fix(api): resolve issue causing the example API to fail in monorepos
1 parent 5af44e5 commit c5bcff8

File tree

4 files changed

+237
-22
lines changed

4 files changed

+237
-22
lines changed
Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
import { GET } from '../../../../../../../../../pages/api/[version]/[section]/[page]/[tab]/examples/[example]'
2+
import { access, readFile } from 'fs/promises'
3+
4+
jest.mock('fs/promises')
5+
const mockReadFile = readFile as jest.MockedFunction<typeof readFile>
6+
const mockAccess = access as jest.MockedFunction<typeof access>
7+
8+
jest.mock('../../../../../../../../../content', () => ({
9+
content: [
10+
{
11+
name: 'react-component-docs',
12+
base: '/mock/monorepo/packages/react-core',
13+
pattern: '**/*.md',
14+
version: 'v6',
15+
},
16+
],
17+
}))
18+
19+
jest.mock('astro:content', () => ({
20+
getCollection: jest.fn((collectionName: string) => {
21+
const mockData: Record<string, any[]> = {
22+
'react-component-docs': [
23+
{
24+
id: 'components/alert/react',
25+
slug: 'components/alert/react',
26+
body: '',
27+
filePath: 'patternfly-docs/components/Alert/examples/Alert.md',
28+
data: {
29+
id: 'Alert',
30+
title: 'Alert',
31+
section: 'components',
32+
tab: 'react',
33+
},
34+
collection: 'react-component-docs',
35+
},
36+
],
37+
}
38+
return Promise.resolve(mockData[collectionName] || [])
39+
}),
40+
}))
41+
42+
jest.mock('../../../../../../../../../utils', () => ({
43+
kebabCase: jest.fn((id: string) => {
44+
if (!id) { return '' }
45+
return id
46+
.replace(/([a-z])([A-Z])/g, '$1-$2')
47+
.replace(/[\s_]+/g, '-')
48+
.toLowerCase()
49+
}),
50+
getDefaultTabForApi: jest.fn((filePath?: string) => {
51+
if (!filePath) { return 'react' }
52+
if (filePath.includes('react')) { return 'react' }
53+
return 'react'
54+
}),
55+
addDemosOrDeprecated: jest.fn((tabName: string, filePath?: string) => {
56+
if (!filePath || !tabName) { return '' }
57+
return tabName
58+
}),
59+
addSubsection: jest.fn((page: string, subsection?: string) => {
60+
if (!subsection) { return page }
61+
return `${subsection.toLowerCase()}_${page}`
62+
}),
63+
}))
64+
65+
jest.mock('../../../../../../../../../utils/apiIndex/generate', () => ({
66+
generateAndWriteApiIndex: jest.fn().mockResolvedValue({
67+
versions: ['v6'],
68+
sections: { v6: ['components'] },
69+
pages: { 'v6::components': ['alert'] },
70+
tabs: { 'v6::components::alert': ['react'] },
71+
examples: {
72+
'v6::components::alert::react': [
73+
{ exampleName: 'AlertBasic' },
74+
],
75+
},
76+
}),
77+
}))
78+
79+
beforeEach(() => {
80+
jest.clearAllMocks()
81+
})
82+
83+
const mdxContent = `
84+
import AlertBasic from './AlertBasic.tsx?raw'
85+
import AlertCustomIcon from './AlertCustomIcon.tsx?raw'
86+
`
87+
88+
it('resolves example files relative to base in monorepo setups', async () => {
89+
// Simulate monorepo: raw filePath doesn't exist at CWD, so access rejects
90+
mockAccess.mockRejectedValueOnce(new Error('ENOENT'))
91+
92+
// First call reads the content entry file, second reads the example file
93+
mockReadFile
94+
.mockResolvedValueOnce(mdxContent)
95+
.mockResolvedValueOnce('const AlertBasic = () => <Alert />')
96+
97+
const response = await GET({
98+
params: {
99+
version: 'v6',
100+
section: 'components',
101+
page: 'alert',
102+
tab: 'react',
103+
example: 'AlertBasic',
104+
},
105+
} as any)
106+
107+
expect(response.status).toBe(200)
108+
const text = await response.text()
109+
expect(text).toBe('const AlertBasic = () => <Alert />')
110+
111+
// Content entry file should be resolved with base
112+
expect(mockReadFile).toHaveBeenCalledWith(
113+
'/mock/monorepo/packages/react-core/patternfly-docs/components/Alert/examples/Alert.md',
114+
'utf8'
115+
)
116+
117+
// Example file should be resolved with base + content entry dir
118+
expect(mockReadFile).toHaveBeenCalledWith(
119+
'/mock/monorepo/packages/react-core/patternfly-docs/components/Alert/examples/AlertBasic.tsx',
120+
'utf8'
121+
)
122+
})
123+
124+
it('returns 404 when example is not found in imports', async () => {
125+
mockAccess.mockRejectedValueOnce(new Error('ENOENT'))
126+
mockReadFile.mockResolvedValueOnce(mdxContent)
127+
128+
const response = await GET({
129+
params: {
130+
version: 'v6',
131+
section: 'components',
132+
page: 'alert',
133+
tab: 'react',
134+
example: 'NonExistent',
135+
},
136+
} as any)
137+
138+
expect(response.status).toBe(404)
139+
const body = await response.json()
140+
expect(body.error).toContain('NonExistent')
141+
})
142+
143+
it('returns 404 when example file does not exist on disk', async () => {
144+
mockAccess.mockRejectedValueOnce(new Error('ENOENT'))
145+
146+
const enoentError = new Error('ENOENT: no such file or directory') as NodeJS.ErrnoException
147+
enoentError.code = 'ENOENT'
148+
149+
mockReadFile
150+
.mockResolvedValueOnce(mdxContent as any)
151+
.mockRejectedValueOnce(enoentError)
152+
153+
const response = await GET({
154+
params: {
155+
version: 'v6',
156+
section: 'components',
157+
page: 'alert',
158+
tab: 'react',
159+
example: 'AlertBasic',
160+
},
161+
} as any)
162+
163+
const body = await response.json()
164+
expect(response.status).toBe(404)
165+
expect(body.error).toContain('Example file not found')
166+
})
167+
168+
it('returns 400 when required parameters are missing', async () => {
169+
const response = await GET({
170+
params: {
171+
version: 'v6',
172+
section: 'components',
173+
page: 'alert',
174+
tab: 'react',
175+
},
176+
} as any)
177+
178+
expect(response.status).toBe(400)
179+
const body = await response.json()
180+
expect(body.error).toContain('required')
181+
})
182+
183+
it('returns 404 when content entry is not found', async () => {
184+
const response = await GET({
185+
params: {
186+
version: 'v6',
187+
section: 'components',
188+
page: 'nonexistent',
189+
tab: 'react',
190+
example: 'AlertBasic',
191+
},
192+
} as any)
193+
194+
const body = await response.json()
195+
expect(response.status).toBe(404)
196+
expect(body.error).toContain('Content entry not found')
197+
})

src/pages/api/[version]/[section]/[page]/[tab]/examples/[example].ts

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11

22
import type { APIRoute, GetStaticPaths } from 'astro'
3-
import { readFile } from 'fs/promises'
4-
import { resolve } from 'path'
3+
import { access, readFile } from 'fs/promises'
4+
import { isAbsolute, resolve } from 'path'
55
import { createJsonResponse, createTextResponse } from '../../../../../../../utils/apiHelpers'
66
import { generateAndWriteApiIndex } from '../../../../../../../utils/apiIndex/generate'
77
import { getEnrichedCollections } from '../../../../../../../utils/apiRoutes/collections'
@@ -66,23 +66,38 @@ export const GET: APIRoute = async ({ params }) => {
6666

6767
try {
6868
const collections = await getEnrichedCollections(version)
69-
const contentEntryFilePath = findContentEntryFilePath(collections, {
69+
const contentEntryMatch = findContentEntryFilePath(collections, {
7070
section,
7171
page,
7272
tab
7373
})
7474

75-
if (!contentEntryFilePath) {
75+
if (!contentEntryMatch) {
7676
return createJsonResponse(
7777
{ error: `Content entry not found for ${version}/${section}/${page}/${tab}` },
7878
404
7979
)
8080
}
8181

82+
const { filePath: contentEntryFilePath, base } = contentEntryMatch
83+
84+
// Resolve the content entry file path.
85+
// In non-monorepo setups, filePath is relative to CWD and resolves directly.
86+
// In monorepo setups, filePath may be relative to `base` instead of CWD.
87+
// We try the original path first, then fall back to resolve(base, filePath).
88+
let resolvedContentPath = contentEntryFilePath
89+
if (base && !isAbsolute(contentEntryFilePath)) {
90+
try {
91+
await access(contentEntryFilePath)
92+
} catch {
93+
resolvedContentPath = resolve(base, contentEntryFilePath)
94+
}
95+
}
96+
8297
// Read content entry file to extract imports
8398
let contentEntryFileContent: string
8499
try {
85-
contentEntryFileContent = await readFile(contentEntryFilePath, 'utf8')
100+
contentEntryFileContent = await readFile(resolvedContentPath, 'utf8')
86101
} catch (error) {
87102
const details = error instanceof Error ? error.message : String(error)
88103
return createJsonResponse(
@@ -110,8 +125,8 @@ export const GET: APIRoute = async ({ params }) => {
110125
// Strip query parameters (like ?raw) from the file path before reading
111126
const cleanFilePath = relativeExampleFilePath.split('?')[0]
112127

113-
// Read example file
114-
const absoluteExampleFilePath = resolve(contentEntryFilePath, '../', cleanFilePath)
128+
// Read example file, resolving relative to the content entry file's directory
129+
const absoluteExampleFilePath = resolve(resolvedContentPath, '../', cleanFilePath)
115130
let exampleFileContent: string
116131
try {
117132
exampleFileContent = await readFile(absoluteExampleFilePath, 'utf8')

src/utils/apiRoutes/collections.ts

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { getDefaultTabForApi } from '../packageUtils'
55

66
export type EnrichedContentEntry = {
77
filePath: string
8+
base?: string
89
data: {
910
tab: string
1011
[key: string]: any
@@ -20,20 +21,22 @@ export type EnrichedContentEntry = {
2021
* @returns Promise resolving to array of collection entries with enriched metadata
2122
*/
2223
export async function getEnrichedCollections(version: string): Promise<EnrichedContentEntry[]> {
23-
const collectionsToFetch = content
24-
.filter((entry) => entry.version === version)
25-
.map((entry) => entry.name as CollectionKey)
24+
const contentEntries = content.filter((entry) => entry.version === version)
2625

2726
const collections = await Promise.all(
28-
collectionsToFetch.map((name) => getCollection(name))
27+
contentEntries.map((entry) => getCollection(entry.name as CollectionKey))
2928
)
3029

31-
return collections.flat().map(({ data, filePath, ...rest }) => ({
32-
filePath,
33-
...rest,
34-
data: {
35-
...data,
36-
tab: data.tab || data.source || getDefaultTabForApi(filePath),
37-
},
38-
}))
30+
return collections.flatMap((collectionEntries, index) => {
31+
const base = contentEntries[index].base
32+
return collectionEntries.map(({ data, filePath = '', ...rest }) => ({
33+
filePath,
34+
base,
35+
...rest,
36+
data: {
37+
...data,
38+
tab: data.tab || data.source || getDefaultTabForApi(filePath),
39+
},
40+
}))
41+
})
3942
}

src/utils/apiRoutes/contentMatching.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -66,12 +66,12 @@ export function findContentEntry(
6666
* @param entries - Array of enriched content entries to search
6767
* @param params - Parameters to match against (section, page, tab)
6868
* - page may be underscore-separated for subsection pages (e.g., "forms_checkbox")
69-
* @returns The file path, or null if not found
69+
* @returns Object with filePath and optional base, or null if not found
7070
*/
7171
export function findContentEntryFilePath(
7272
entries: EnrichedContentEntry[],
7373
params: ContentMatchParams
74-
): string | null {
74+
): { filePath: string; base?: string } | null {
7575
// Find all matching entries using shared matching logic
7676
const matchingEntries = entries.filter((entry) => matchesParams(entry, params))
7777

@@ -83,5 +83,5 @@ export function findContentEntryFilePath(
8383
const mdxEntry = matchingEntries.find((entry) => entry.filePath.endsWith('.mdx'))
8484
const selectedEntry = mdxEntry || matchingEntries[0]
8585

86-
return selectedEntry.filePath
86+
return { filePath: selectedEntry.filePath, base: selectedEntry.base }
8787
}

0 commit comments

Comments
 (0)