diff --git a/.gitignore b/.gitignore index 7c0cb99..5efbdc5 100644 --- a/.gitignore +++ b/.gitignore @@ -34,3 +34,7 @@ src/apiIndex.json textContent/*.mdx coverage/ + +.wrangler/ + +temp \ No newline at end of file diff --git a/README.mdx b/README.mdx index 26c5fce..5d9e7ea 100644 --- a/README.mdx +++ b/README.mdx @@ -33,6 +33,7 @@ Any static assets, like images, can be placed in the `public/` directory. To define the markdown schema this project uses a typescript based schema known as [Zod](https://zod.dev). Details of how this is integratred into Astro can be found in Astros documentation on [content creation using Zod](https://docs.astro.build/en/guides/content-collections/#defining-datatypes-with-zod). +Note: When running in dev mode locally on a clean repository, API endpoints will not be available until you run `npm run build` to generate the API index. ### 🧞 Commands All commands are run from the root of the project, from a terminal: diff --git a/cli/cli.ts b/cli/cli.ts index 95f250f..58143cc 100755 --- a/cli/cli.ts +++ b/cli/cli.ts @@ -34,6 +34,7 @@ try { .replace('file://', '') } catch (e: any) { if (e.code === 'ERR_MODULE_NOT_FOUND') { + console.log('@patternfly/patternfly-doc-core not found, using current directory as astroRoot') astroRoot = process.cwd() } else { console.error('Error resolving astroRoot', e) @@ -87,29 +88,33 @@ async function transformMDContentToMDX() { } } -async function initializeApiIndex() { +async function initializeApiIndex(program: Command) { + const { verbose } = program.opts() const templateIndexPath = join(astroRoot, 'cli', 'templates', 'apiIndex.json') - const targetIndexPath = join(astroRoot, 'src', 'apiIndex.json') - + const targetIndexPath = join(absoluteOutputDir, 'apiIndex.json') const indexExists = await fileExists(targetIndexPath) // early return if the file exists from a previous build if (indexExists) { - console.log('apiIndex.json already exists, skipping initialization') + if (verbose) { + console.log('apiIndex.json already exists, skipping initialization') + } return } try { await copyFile(templateIndexPath, targetIndexPath) - console.log('Initialized apiIndex.json') + if (verbose) { + console.log('Initialized apiIndex.json') + } } catch (e: any) { console.error('Error copying apiIndex.json template:', e) } } -async function buildProject(): Promise { - await updateContent(program) - await generateProps(program, true) +async function buildProject(program: Command): Promise { + const { verbose } = program.opts() + if (!config) { console.error( 'No config found, please run the `setup` command or manually create a pf-docs.config.mjs file', @@ -123,44 +128,57 @@ async function buildProject(): Promise { ) return config } - - await initializeApiIndex() + await updateContent(program) + await generateProps(program, true) + await initializeApiIndex(program) await transformMDContentToMDX() - build({ + const docsOutputDir = join(absoluteOutputDir, 'docs') + + await build({ root: astroRoot, - outDir: join(absoluteOutputDir, 'docs'), + outDir: docsOutputDir, }) + // copy the apiIndex.json file to the docs directory so it can be served as a static asset + try { + const apiIndexPath = join(absoluteOutputDir, 'apiIndex.json') + const docsApiIndexPath = join(absoluteOutputDir, 'docs', 'apiIndex.json') + await copyFile(apiIndexPath, docsApiIndexPath) + + if (verbose) { + console.log('Copied apiIndex.json to docs directory') + } + } catch (error) { + console.error('Failed to copy apiIndex.json to docs directory:', error) + throw error + } + return config } -async function deploy() { - const { verbose } = program.opts() +async function deploy(program: Command) { + const { verbose, dryRun } = program.opts() if (verbose) { console.log('Starting Cloudflare deployment...') } + if (dryRun) { + console.log('Dry run mode enabled, skipping deployment') + return + } + try { - // First build the project - const config = await buildProject() - if (config) { - if (verbose) { - console.log('Build complete, deploying to Cloudflare...') - } - - // Deploy using Wrangler - const { execSync } = await import('child_process') - const outputPath = join(absoluteOutputDir, 'docs') - - execSync(`npx wrangler pages deploy ${outputPath}`, { - stdio: 'inherit', - cwd: currentDir, - }) - - console.log('Successfully deployed to Cloudflare Pages!') - } + // Deploy using Wrangler + const { execSync } = await import('child_process') + + execSync(`wrangler pages deploy`, { + stdio: 'inherit', + cwd: currentDir, + }) + + console.log('Successfully deployed to Cloudflare Pages!') } catch (error) { console.error('Deployment failed:', error) process.exit(1) @@ -172,6 +190,7 @@ program.name('pf-doc-core') program.option('--verbose', 'verbose mode', false) program.option('--props', 'generate props data', false) +program.option('--dry-run', 'dry run mode', false) program.command('setup').action(async () => { await Promise.all([ @@ -194,7 +213,7 @@ program.command('init').action(async () => { program.command('start').action(async () => { await updateContent(program) - await initializeApiIndex() + await initializeApiIndex(program) // if a props file hasn't been generated yet, but the consumer has propsData, it will cause a runtime error so to // prevent that we're just creating a props file regardless of what they say if one doesn't exist yet @@ -204,7 +223,7 @@ program.command('start').action(async () => { }) program.command('build').action(async () => { - await buildProject() + await buildProject(program) }) program.command('generate-props').action(async () => { @@ -229,7 +248,7 @@ program }) program.command('deploy').action(async () => { - await deploy() + await deploy(program) }) program.parse(process.argv) diff --git a/jest.config.ts b/jest.config.ts index d9d5be4..8348831 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -17,8 +17,8 @@ const config: Config = { moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], moduleNameMapper: { '\\.(css|less)$': '/src/__mocks__/styleMock.ts', - '(.+)\\.js': '$1', '^astro:content$': '/src/__mocks__/astro-content.ts', + '(.+)\\.js': '$1', }, setupFilesAfterEnv: ['/test.setup.ts'], transformIgnorePatterns: [ diff --git a/package-lock.json b/package-lock.json index 2623c98..9bc293c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,7 +26,7 @@ "@patternfly/react-tokens": "^6.0.0", "@types/react": "^18.3.23", "@types/react-dom": "^18.3.7", - "astro": "5.15.9", + "astro": "^5.15.9", "change-case": "5.4.4", "commander": "^13.1.0", "glob": "^11.0.3", diff --git a/package.json b/package.json index 9fee6b4..bfe7cf6 100644 --- a/package.json +++ b/package.json @@ -18,14 +18,15 @@ "build:props": "npm run build:cli && node ./dist/cli/cli.js generate-props", "preview": "wrangler pages dev", "astro": "astro", - "deploy": "wrangler pages deploy", + "deploy": "npm run build:cli && node ./dist/cli/cli.js deploy", "versions:upload": "wrangler versions upload", "prettier": "prettier --write ./src", "lint": "eslint . --cache --cache-strategy content", "test": "jest", "test:watch": "jest --watch", "semantic-release": "semantic-release", - "cf-typegen": "wrangler types" + "cf-typegen": "wrangler types", + "clean": "rm -rf dist .astro .wrangler" }, "main": "dist/cli/cli.js", "bin": "./dist/cli/cli.js", @@ -64,7 +65,7 @@ "@patternfly/quickstarts": "^6.0.0", "@types/react": "^18.3.23", "@types/react-dom": "^18.3.7", - "astro": "5.15.9", + "astro": "^5.15.9", "change-case": "5.4.4", "commander": "^13.1.0", "glob": "^11.0.3", diff --git a/src/__tests__/pages/api/__tests__/[version].test.ts b/src/__tests__/pages/api/__tests__/[version].test.ts index 531f69c..ab1a511 100644 --- a/src/__tests__/pages/api/__tests__/[version].test.ts +++ b/src/__tests__/pages/api/__tests__/[version].test.ts @@ -1,10 +1,6 @@ import { GET } from '../../../../pages/api/[version]' -/** - * Mock apiIndex.json with multiple versions (v5, v6) - * to test section retrieval for different versions - */ -jest.mock('../../../../apiIndex.json', () => ({ +const mockApiIndex = { versions: ['v5', 'v6'], sections: { v5: ['getting-started'], @@ -12,11 +8,19 @@ jest.mock('../../../../apiIndex.json', () => ({ }, pages: {}, tabs: {}, -})) +} it('returns all sections for a valid version', async () => { + global.fetch = jest.fn(() => + Promise.resolve({ + ok: true, + json: () => Promise.resolve(mockApiIndex), + } as Response) + ) + const response = await GET({ params: { version: 'v6' }, + url: new URL('http://localhost:4321/api/v6'), } as any) const body = await response.json() @@ -26,41 +30,81 @@ it('returns all sections for a valid version', async () => { expect(body).toContain('components') expect(body).toContain('layouts') expect(body).toContain('utilities') + + jest.restoreAllMocks() }) it('returns only sections for the requested version', async () => { + global.fetch = jest.fn(() => + Promise.resolve({ + ok: true, + json: () => Promise.resolve(mockApiIndex), + } as Response) + ) + const response = await GET({ params: { version: 'v5' }, + url: new URL('http://localhost:4321/api/v5'), } as any) const body = await response.json() expect(response.status).toBe(200) expect(body).toContain('getting-started') + + jest.restoreAllMocks() }) it('sorts sections alphabetically', async () => { + global.fetch = jest.fn(() => + Promise.resolve({ + ok: true, + json: () => Promise.resolve(mockApiIndex), + } as Response) + ) + const response = await GET({ params: { version: 'v6' }, + url: new URL('http://localhost:4321/api/v6'), } as any) const body = await response.json() const sorted = [...body].sort() expect(body).toEqual(sorted) + + jest.restoreAllMocks() }) it('deduplicates sections from multiple collections', async () => { + global.fetch = jest.fn(() => + Promise.resolve({ + ok: true, + json: () => Promise.resolve(mockApiIndex), + } as Response) + ) + const response = await GET({ params: { version: 'v6' }, + url: new URL('http://localhost:4321/api/v6'), } as any) const body = await response.json() const unique = [...new Set(body)] expect(body).toEqual(unique) + + jest.restoreAllMocks() }) it('returns 404 error for nonexistent version', async () => { + global.fetch = jest.fn(() => + Promise.resolve({ + ok: true, + json: () => Promise.resolve(mockApiIndex), + } as Response) + ) + const response = await GET({ params: { version: 'v99' }, + url: new URL('http://localhost:4321/api/v99'), } as any) const body = await response.json() @@ -68,25 +112,49 @@ it('returns 404 error for nonexistent version', async () => { 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 = jest.fn(() => + Promise.resolve({ + ok: true, + json: () => Promise.resolve(mockApiIndex), + } as Response) + ) + const response = await GET({ params: {}, + url: new URL('http://localhost:4321/api/'), } 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('excludes content entries that have no section field', async () => { +it('returns sections array that matches the API index', async () => { + global.fetch = jest.fn(() => + Promise.resolve({ + ok: true, + json: () => Promise.resolve(mockApiIndex), + } as Response) + ) + const response = await GET({ params: { version: 'v6' }, + url: new URL('http://localhost:4321/api/v6'), } as any) const body = await response.json() - // Should only include sections from entries that have data.section - expect(body.length).toBeGreaterThan(0) + // Verify the returned sections exactly match the indexed sections + // The API index generation process filters out entries without section fields + expect(body).toEqual(mockApiIndex.sections.v6) + expect(body).toEqual(['components', 'layouts', 'utilities']) + + jest.restoreAllMocks() }) diff --git a/src/__tests__/pages/api/__tests__/[version]/[section]/[page]/[tab].test.ts b/src/__tests__/pages/api/__tests__/[version]/[section]/[page]/[tab].test.ts index 656c68e..acd9784 100644 --- a/src/__tests__/pages/api/__tests__/[version]/[section]/[page]/[tab].test.ts +++ b/src/__tests__/pages/api/__tests__/[version]/[section]/[page]/[tab].test.ts @@ -86,7 +86,7 @@ jest.mock('../../../../../../../utils', () => ({ .replace(/[\s_]+/g, '-') .toLowerCase() }), - getDefaultTab: jest.fn((filePath?: string) => { + getDefaultTabForApi: jest.fn((filePath?: string) => { if (!filePath) { return 'react' } diff --git a/src/__tests__/pages/api/__tests__/versions.test.ts b/src/__tests__/pages/api/__tests__/versions.test.ts index 5a905c4..026ca61 100644 --- a/src/__tests__/pages/api/__tests__/versions.test.ts +++ b/src/__tests__/pages/api/__tests__/versions.test.ts @@ -1,36 +1,66 @@ import { GET } from '../../../../pages/api/versions' -/** - * Mock apiIndex.json with multiple versions - */ -jest.mock('../../../../apiIndex.json', () => ({ +const mockApiIndex = { versions: ['v5', 'v6'], sections: {}, pages: {}, tabs: {}, -})) +} it('returns unique versions as sorted array', async () => { - const response = await GET({} as any) + global.fetch = jest.fn(() => + Promise.resolve({ + ok: true, + json: () => Promise.resolve(mockApiIndex), + } as Response) + ) + + const response = await GET({ + url: new URL('http://localhost:4321/api/versions'), + } 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).toEqual(['v5', 'v6']) + + jest.restoreAllMocks() }) it('sorts versions alphabetically', async () => { - const response = await GET({} as any) + global.fetch = jest.fn(() => + Promise.resolve({ + ok: true, + json: () => Promise.resolve(mockApiIndex), + } as Response) + ) + + const response = await GET({ + url: new URL('http://localhost:4321/api/versions'), + } as any) const body = await response.json() expect(body).toEqual(['v5', 'v6']) + + jest.restoreAllMocks() }) it('returns only the versions from the index', async () => { - const response = await GET({} as any) + global.fetch = jest.fn(() => + Promise.resolve({ + ok: true, + json: () => Promise.resolve(mockApiIndex), + } as Response) + ) + + const response = await GET({ + url: new URL('http://localhost:4321/api/versions'), + } as any) const body = await response.json() // Should return exactly the versions from the mocked apiIndex.json expect(body).toEqual(['v5', 'v6']) expect(body).toHaveLength(2) + + jest.restoreAllMocks() }) diff --git a/src/pages/api/[version].ts b/src/pages/api/[version].ts index f103040..8397aee 100644 --- a/src/pages/api/[version].ts +++ b/src/pages/api/[version].ts @@ -1,10 +1,10 @@ import type { APIRoute } from 'astro' -import { createJsonResponse, createIndexKey } from '../../utils/apiHelpers' -import { sections as sectionsData } from '../../apiIndex.json' +import { createJsonResponse } from '../../utils/apiHelpers' +import { fetchApiIndex } from '../../utils/apiIndex/fetch' export const prerender = false -export const GET: APIRoute = async ({ params }) => { +export const GET: APIRoute = async ({ params, url }) => { const { version } = params if (!version) { @@ -14,12 +14,19 @@ export const GET: APIRoute = async ({ params }) => { ) } - const key = createIndexKey(version) - const sections = sectionsData[key as keyof typeof sectionsData] + try { + const index = await fetchApiIndex(url) + const sections = index.sections[version] - if (!sections) { - return createJsonResponse({ error: `Version '${version}' not found` }, 404) - } + if (!sections) { + return createJsonResponse({ error: `Version '${version}' not found` }, 404) + } - return createJsonResponse(sections) + return createJsonResponse(sections) + } catch (error) { + return createJsonResponse( + { error: 'Failed to load API index', details: error }, + 500 + ) + } } diff --git a/src/pages/api/[version]/[section].ts b/src/pages/api/[version]/[section].ts index cf35963..1db5e95 100644 --- a/src/pages/api/[version]/[section].ts +++ b/src/pages/api/[version]/[section].ts @@ -1,10 +1,10 @@ import type { APIRoute } from 'astro' import { createJsonResponse, createIndexKey } from '../../../utils/apiHelpers' -import { pages as pagesData } from '../../../apiIndex.json' +import { fetchApiIndex } from '../../../utils/apiIndex/fetch' export const prerender = false -export const GET: APIRoute = async ({ params }) => { +export const GET: APIRoute = async ({ params, url }) => { const { version, section } = params if (!version || !section) { @@ -14,15 +14,24 @@ export const GET: APIRoute = async ({ params }) => { ) } - const key = createIndexKey(version, section) - const pages = pagesData[key as keyof typeof pagesData] + try { + const index = await fetchApiIndex(url) + const key = createIndexKey(version, section) + const pages = index.pages[key] - if (!pages) { + if (!pages) { + return createJsonResponse( + { error: `Section '${section}' not found for version '${version}'` }, + 404, + ) + } + + return createJsonResponse(pages) + } catch (error) { + const details = error instanceof Error ? error.message : String(error) return createJsonResponse( - { error: `Section '${section}' not found for version '${version}'` }, - 404, + { error: 'Failed to load API index', details }, + 500 ) } - - return createJsonResponse(pages) } diff --git a/src/pages/api/[version]/[section]/[page].ts b/src/pages/api/[version]/[section]/[page].ts index 456e227..9ccbf6e 100644 --- a/src/pages/api/[version]/[section]/[page].ts +++ b/src/pages/api/[version]/[section]/[page].ts @@ -1,10 +1,10 @@ import type { APIRoute } from 'astro' import { createJsonResponse, createIndexKey } from '../../../../utils/apiHelpers' -import { tabs as tabsData } from '../../../../apiIndex.json' +import { fetchApiIndex } from '../../../../utils/apiIndex/fetch' export const prerender = false -export const GET: APIRoute = async ({ params }) => { +export const GET: APIRoute = async ({ params, url }) => { const { version, section, page } = params if (!version || !section || !page) { @@ -14,17 +14,25 @@ export const GET: APIRoute = async ({ params }) => { ) } - const key = createIndexKey(version, section, page) - const tabs = tabsData[key as keyof typeof tabsData] + try { + const index = await fetchApiIndex(url) + const key = createIndexKey(version, section, page) + const tabs = index.tabs[key] - if (!tabs) { + if (!tabs) { + return createJsonResponse( + { + error: `Page '${page}' not found in section '${section}' for version '${version}'`, + }, + 404, + ) + } + + return createJsonResponse(tabs) + } catch (error) { return createJsonResponse( - { - error: `Page '${page}' not found in section '${section}' for version '${version}'`, - }, - 404, + { error: 'Failed to load API index', details: error }, + 500 ) } - - return createJsonResponse(tabs) } diff --git a/src/pages/api/[version]/[section]/[page]/[tab].ts b/src/pages/api/[version]/[section]/[page]/[tab].ts index 9af8e57..d770764 100644 --- a/src/pages/api/[version]/[section]/[page]/[tab].ts +++ b/src/pages/api/[version]/[section]/[page]/[tab].ts @@ -5,8 +5,8 @@ import { getCollection } from 'astro:content' import { content } from '../../../../../content' import { kebabCase, - getDefaultTab, addDemosOrDeprecated, + getDefaultTabForApi, } from '../../../../../utils' import { generateAndWriteApiIndex } from '../../../../../utils/apiIndex/generate' import { getApiIndex } from '../../../../../utils/apiIndex/get' @@ -44,7 +44,15 @@ export const getStaticPaths: GetStaticPaths = async () => { } } - return paths + // This shouldn't happen since we have a fallback tab value, but if it somehow does we need to alert the user + paths.forEach((path) => { + if (!path.params.tab) { + console.warn(`[API Warning] Tab not found for path: ${path.params.version}/${path.params.section}/${path.params.page}`) + } + }) + + // Again, this shouldn't happen since we have a fallback tab value, but if it somehow does and we don't filter out tabless paths it will crash the build + return paths.filter((path) => !!path.params.tab) } export const GET: APIRoute = async ({ params }) => { @@ -109,7 +117,7 @@ export const GET: APIRoute = async ({ params }) => { ...rest, data: { ...data, - tab: data.tab || data.source || getDefaultTab(filePath), + tab: data.tab || data.source || getDefaultTabForApi(filePath), }, })) diff --git a/src/pages/api/openapi.json.ts b/src/pages/api/openapi.json.ts index af94756..8dd3c07 100644 --- a/src/pages/api/openapi.json.ts +++ b/src/pages/api/openapi.json.ts @@ -1,10 +1,21 @@ import type { APIRoute } from 'astro' -import index from '../../apiIndex.json' +import { fetchApiIndex } from '../../utils/apiIndex/fetch' +import { createJsonResponse } from '../../utils/apiHelpers' export const prerender = false -export const GET: APIRoute = async () => { - const versions = index.versions +export const GET: APIRoute = async ({ url }) => { + let versions: string[] + try { + const index = await fetchApiIndex(url) + versions = index.versions + } catch (error) { + const details = error instanceof Error ? error.message : String(error) + return createJsonResponse( + { error: 'Failed to load API index', details }, + 500 + ) + } const openApiSpec = { openapi: '3.0.0', diff --git a/src/pages/api/versions.ts b/src/pages/api/versions.ts index 64e53f5..3b21f82 100644 --- a/src/pages/api/versions.ts +++ b/src/pages/api/versions.ts @@ -1,11 +1,17 @@ import type { APIRoute } from 'astro' import { createJsonResponse } from '../../utils/apiHelpers' -import { versions as versionsData } from '../../apiIndex.json' +import { fetchApiIndex } from '../../utils/apiIndex/fetch' export const prerender = false -export const GET: APIRoute = async () => { - const versions = versionsData - - return createJsonResponse(versions) +export const GET: APIRoute = async ({ url }) => { + try { + const index = await fetchApiIndex(url) + return createJsonResponse(index.versions) + } catch (error) { + return createJsonResponse( + { error: 'Failed to load API index', details: error }, + 500 + ) + } } diff --git a/src/pages/apiIndex.json.ts b/src/pages/apiIndex.json.ts new file mode 100644 index 0000000..aeb1107 --- /dev/null +++ b/src/pages/apiIndex.json.ts @@ -0,0 +1,27 @@ +import type { APIRoute } from 'astro' +import { getApiIndex } from '../utils/apiIndex/get' + +// Prerender at build time so this doesn't run in the Cloudflare Worker +export const prerender = true + +export const GET: APIRoute = async () => { + try { + const index = await getApiIndex() + return new Response(JSON.stringify(index), { + status: 200, + headers: { + 'Content-Type': 'application/json', + }, + }) + } catch (error) { + return new Response( + JSON.stringify({ error: 'Failed to load API index', details: error }), + { + status: 500, + headers: { + 'Content-Type': 'application/json', + }, + } + ) + } +} diff --git a/src/utils/__tests__/packageUtils.test.ts b/src/utils/__tests__/packageUtils.test.ts index fbbc4fe..72faa81 100644 --- a/src/utils/__tests__/packageUtils.test.ts +++ b/src/utils/__tests__/packageUtils.test.ts @@ -1,4 +1,4 @@ -import { getPackageName, getTabBase, getDefaultTab, addDemosOrDeprecated } from '../packageUtils' +import { getPackageName, getTabBase, getDefaultTab, getDefaultTabForApi, addDemosOrDeprecated } from '../packageUtils' describe('getPackageName', () => { it('returns empty string for empty input', () => { @@ -192,3 +192,70 @@ describe('getDefaultTab', () => { expect(getDefaultTab(filePath)).toBe('') }) }) + +describe('getDefaultTabForApi', () => { + it('returns base tab for regular patternfly package path', () => { + const filePath = '/path/to/node_modules/@patternfly/patternfly/dist/index.js' + expect(getDefaultTabForApi(filePath)).toBe('html') + }) + + it('returns base tab for regular react-core package path', () => { + const filePath = '/path/to/node_modules/@patternfly/react-core/dist/index.js' + expect(getDefaultTabForApi(filePath)).toBe('react') + }) + + it('returns demos tab for demos path with patternfly package', () => { + const filePath = '/path/to/node_modules/@patternfly/patternfly/demos/Button.js' + expect(getDefaultTabForApi(filePath)).toBe('html-demos') + }) + + it('returns demos tab for demos path with react-core package', () => { + const filePath = '/path/to/node_modules/@patternfly/react-core/demos/Button.js' + expect(getDefaultTabForApi(filePath)).toBe('react-demos') + }) + + it('returns deprecated tab for deprecated path with patternfly package', () => { + const filePath = '/path/to/node_modules/@patternfly/patternfly/deprecated/OldButton.js' + expect(getDefaultTabForApi(filePath)).toBe('html-deprecated') + }) + + it('returns deprecated tab for deprecated path with react-core package', () => { + const filePath = '/path/to/node_modules/@patternfly/react-core/deprecated/OldButton.js' + expect(getDefaultTabForApi(filePath)).toBe('react-deprecated') + }) + + it('adds both demos and deprecated when both are in path', () => { + const filePath = '/path/to/node_modules/@patternfly/react-core/demos/deprecated/Button.js' + expect(getDefaultTabForApi(filePath)).toBe('react-demos-deprecated') + }) + + it('returns "text" fallback for unknown package', () => { + const filePath = '/path/to/node_modules/unknown-package/dist/index.js' + expect(getDefaultTabForApi(filePath)).toBe('text') + }) + + it('returns "text" fallback for path without node_modules', () => { + const filePath = '/path/to/some/file.js' + expect(getDefaultTabForApi(filePath)).toBe('text') + }) + + it('returns "text" fallback for empty input', () => { + expect(getDefaultTabForApi('')).toBe('text') + }) + + it('returns "text" fallback for null/undefined input', () => { + expect(getDefaultTabForApi(null as any)).toBe('text') + expect(getDefaultTabForApi(undefined)).toBe('text') + expect(getDefaultTabForApi()).toBe('text') + }) + + it('returns "text" fallback for unknown package with demos path', () => { + const filePath = '/path/to/node_modules/unknown-package/demos/Button.js' + expect(getDefaultTabForApi(filePath)).toBe('text') + }) + + it('returns "text" fallback for unknown package with deprecated path', () => { + const filePath = '/path/to/node_modules/unknown-package/deprecated/Button.js' + expect(getDefaultTabForApi(filePath)).toBe('text') + }) +}) diff --git a/src/utils/apiIndex/fetch.ts b/src/utils/apiIndex/fetch.ts new file mode 100644 index 0000000..ec1a3dc --- /dev/null +++ b/src/utils/apiIndex/fetch.ts @@ -0,0 +1,20 @@ +import type { ApiIndex } from './generate' + +/** + * Fetches the API index from the server as a static asset + * Used by API routes at runtime instead of importing the JSON statically + * + * @param url - The URL object from the API route context + * @returns Promise resolving to the API index structure + */ +export async function fetchApiIndex(url: URL): Promise { + const apiIndexUrl = new URL('/apiIndex.json', url.origin) + const response = await fetch(apiIndexUrl) + + if (!response.ok) { + throw new Error(`Failed to load API index: ${response.status} ${response.statusText}`) + } + + const data = (await response.json()) as ApiIndex + return data +} diff --git a/src/utils/apiIndex/generate.ts b/src/utils/apiIndex/generate.ts index 6fab08a..6e47e28 100644 --- a/src/utils/apiIndex/generate.ts +++ b/src/utils/apiIndex/generate.ts @@ -1,10 +1,12 @@ /* eslint-disable no-console */ import { join } from 'path' -import { writeFile } from 'fs/promises' +import { writeFile, mkdir } from 'fs/promises' import { getCollection } from 'astro:content' import type { CollectionKey } from 'astro:content' import { content } from '../../content' -import { kebabCase, getDefaultTab, addDemosOrDeprecated } from '../index' +import { kebabCase, addDemosOrDeprecated } from '../index' +import { getDefaultTabForApi } from '../packageUtils' +import { getOutputDir } from '../getOutputDir' const SOURCE_ORDER: Record = { react: 1, @@ -107,7 +109,7 @@ export async function generateApiIndex(): Promise { // Collect tab const entryTab = - entry.data.tab || entry.data.source || getDefaultTab(entry.filePath) + entry.data.tab || entry.data.source || getDefaultTabForApi(entry.filePath) const tab = addDemosOrDeprecated(entryTab, entry.id) if (!pageTabs[pageKey]) { pageTabs[pageKey] = new Set() @@ -131,15 +133,17 @@ export async function generateApiIndex(): Promise { } /** - * Writes API index to src/apiIndex.json + * Writes API index to a apiIndex.json file in the user defined output directory * This file is used by server-side API routes to avoid runtime getCollection() calls * * @param index - The API index structure to write */ export async function writeApiIndex(index: ApiIndex): Promise { - const indexPath = join(process.cwd(), 'src', 'apiIndex.json') + const outputDir = await getOutputDir() + const indexPath = join(outputDir, 'apiIndex.json') try { + await mkdir(outputDir, { recursive: true }) await writeFile(indexPath, JSON.stringify(index, null, 2)) console.log(`✓ Generated API index with ${index.versions.length} versions`) } catch (error) { diff --git a/src/utils/apiIndex/get.ts b/src/utils/apiIndex/get.ts index 1f42458..333b273 100644 --- a/src/utils/apiIndex/get.ts +++ b/src/utils/apiIndex/get.ts @@ -1,6 +1,7 @@ import { join } from 'path' import { readFile } from 'fs/promises' import type { ApiIndex } from './generate' +import { getOutputDir } from '../getOutputDir' /** * Reads and parses the API index file @@ -10,7 +11,8 @@ import type { ApiIndex } from './generate' * @throws Error if index file is not found, contains invalid JSON, or has invalid structure */ export async function getApiIndex(): Promise { - const indexPath = join(process.cwd(), 'src', 'apiIndex.json') + const outputDir = await getOutputDir() + const indexPath = join(outputDir, 'apiIndex.json') try { const content = await readFile(indexPath, 'utf-8') diff --git a/src/utils/getOutputDir.ts b/src/utils/getOutputDir.ts new file mode 100644 index 0000000..698950c --- /dev/null +++ b/src/utils/getOutputDir.ts @@ -0,0 +1,19 @@ +import { join } from 'path' +import { getConfig } from '../../cli/getConfig' + +export async function getOutputDir(): Promise { + const config = await getConfig(join(process.cwd(), 'pf-docs.config.mjs')) + if (!config) { + throw new Error( + 'No config found, please run the `setup` command or manually create a pf-docs.config.mjs file', + ) + } + + if (!config.outputDir) { + throw new Error( + 'No outputDir found in config file, an output directory must be defined in your config file e.g. "dist"', + ) + } + + return config.outputDir +} diff --git a/src/utils/packageUtils.ts b/src/utils/packageUtils.ts index c47babe..154797b 100644 --- a/src/utils/packageUtils.ts +++ b/src/utils/packageUtils.ts @@ -61,7 +61,9 @@ export const getDefaultTab = (filePath?: string): string => { const packageName = getPackageName(filePath) const tabBase = getTabBase(packageName) - const tab = addDemosOrDeprecated(tabBase, filePath) - - return tab + return addDemosOrDeprecated(tabBase, filePath) } + +// This function is specifically for API routes where we need a fallback tab name +// to ensure content is always accessible even when the default tab logic doesn't apply +export const getDefaultTabForApi = (filePath?: string): string => getDefaultTab(filePath) || 'text' diff --git a/tsconfig.json b/tsconfig.json index c6b4d69..157f333 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -7,8 +7,13 @@ "module": "ESNext", "moduleResolution": "bundler", "importHelpers": true, - "verbatimModuleSyntax": false, + "verbatimModuleSyntax": false }, - "include": [".astro/types.d.ts", "**/*"], - "exclude": ["dist"] -} + "include": [ + ".astro/types.d.ts", + "**/*" + ], + "exclude": [ + "dist" + ] +} \ No newline at end of file