From 7e44be4a550766e49641105b3003a16405695fc4 Mon Sep 17 00:00:00 2001 From: Austin Sullivan Date: Tue, 2 Dec 2025 10:53:06 -0500 Subject: [PATCH 1/6] fix(API): refactor API data compilation to be done at build time --- .gitignore | 8 +- cli/__tests__/convertToMDX.test.ts | 5 +- cli/__tests__/fileExists.test.ts | 35 ++ cli/cli.ts | 25 +- cli/convertToMDX.ts | 7 +- cli/createCollectionContent.ts | 5 + cli/fileExists.ts | 7 + cli/getConfig.ts | 1 + cli/templates/apiIndex.json | 6 + src/pages/api/[version].ts | 25 ++ src/pages/api/[version]/[section].ts | 28 ++ src/pages/api/[version]/[section]/[page].ts | 30 ++ .../api/[version]/[section]/[page]/[tab].ts | 134 ++++++ src/pages/api/__tests__/[version].test.ts | 92 +++++ .../[version]/[section]/[page]/[tab].test.ts | 192 +++++++++ src/pages/api/__tests__/versions.test.ts | 36 ++ src/pages/api/index.ts | 172 ++++++++ src/pages/api/openapi.json.ts | 387 ++++++++++++++++++ src/pages/api/versions.ts | 11 + src/utils/__tests__/apiHelpers.test.ts | 231 +++++++++++ src/utils/apiHelpers.ts | 50 +++ src/utils/apiIndex/generate.ts | 157 +++++++ src/utils/apiIndex/get.ts | 92 +++++ src/utils/apiIndex/index.ts | 8 + 24 files changed, 1735 insertions(+), 9 deletions(-) create mode 100644 cli/__tests__/fileExists.test.ts create mode 100644 cli/fileExists.ts create mode 100644 cli/templates/apiIndex.json create mode 100644 src/pages/api/[version].ts create mode 100644 src/pages/api/[version]/[section].ts create mode 100644 src/pages/api/[version]/[section]/[page].ts create mode 100644 src/pages/api/[version]/[section]/[page]/[tab].ts create mode 100644 src/pages/api/__tests__/[version].test.ts create mode 100644 src/pages/api/__tests__/[version]/[section]/[page]/[tab].test.ts create mode 100644 src/pages/api/__tests__/versions.test.ts create mode 100644 src/pages/api/index.ts create mode 100644 src/pages/api/openapi.json.ts create mode 100644 src/pages/api/versions.ts create mode 100644 src/utils/__tests__/apiHelpers.test.ts create mode 100644 src/utils/apiHelpers.ts create mode 100644 src/utils/apiIndex/generate.ts create mode 100644 src/utils/apiIndex/get.ts create mode 100644 src/utils/apiIndex/index.ts diff --git a/.gitignore b/.gitignore index ebe0a8c..7c0cb99 100644 --- a/.gitignore +++ b/.gitignore @@ -28,5 +28,9 @@ pnpm-debug.log* .eslintcache -## Ignore content.ts -src/content.ts \ No newline at end of file +## Ignore generated files +src/content.ts +src/apiIndex.json +textContent/*.mdx + +coverage/ diff --git a/cli/__tests__/convertToMDX.test.ts b/cli/__tests__/convertToMDX.test.ts index 1fb9e21..ddc6676 100644 --- a/cli/__tests__/convertToMDX.test.ts +++ b/cli/__tests__/convertToMDX.test.ts @@ -5,13 +5,16 @@ import { convertToMDX } from '../convertToMDX.ts' jest.mock('fs/promises', () => ({ readFile: jest.fn(), writeFile: jest.fn(), - access: jest.fn().mockResolvedValue(undefined), // Mock access to always resolve (file exists) })) jest.mock('glob', () => ({ glob: jest.fn(), })) +jest.mock('../fileExists', () => ({ + fileExists: jest.fn().mockResolvedValue(true), // Mock fileExists to always return true (file exists) +})) + beforeEach(() => { jest.clearAllMocks() }) diff --git a/cli/__tests__/fileExists.test.ts b/cli/__tests__/fileExists.test.ts new file mode 100644 index 0000000..492769e --- /dev/null +++ b/cli/__tests__/fileExists.test.ts @@ -0,0 +1,35 @@ +import { access } from 'fs/promises' +import { fileExists } from '../fileExists' + +jest.mock('fs/promises', () => ({ + access: jest.fn(), +})) + +it('returns true when file exists', async () => { + ;(access as jest.Mock).mockResolvedValue(undefined) + + const result = await fileExists('/path/to/existing/file.txt') + + expect(result).toBe(true) + expect(access).toHaveBeenCalledWith('/path/to/existing/file.txt') +}) + +it('returns false when file does not exist', async () => { + ;(access as jest.Mock).mockRejectedValue( + new Error('ENOENT: no such file or directory'), + ) + + const result = await fileExists('/path/to/nonexistent/file.txt') + + expect(result).toBe(false) + expect(access).toHaveBeenCalledWith('/path/to/nonexistent/file.txt') +}) + +it('returns false when access throws any error', async () => { + ;(access as jest.Mock).mockRejectedValue(new Error('Permission denied')) + + const result = await fileExists('/path/to/file.txt') + + expect(result).toBe(false) + expect(access).toHaveBeenCalledWith('/path/to/file.txt') +}) diff --git a/cli/cli.ts b/cli/cli.ts index 53b7507..95f250f 100755 --- a/cli/cli.ts +++ b/cli/cli.ts @@ -12,7 +12,8 @@ import { symLinkConfig } from './symLinkConfig.js' import { buildPropsData } from './buildPropsData.js' import { hasFile } from './hasFile.js' import { convertToMDX } from './convertToMDX.js' -import { mkdir } from 'fs/promises' +import { mkdir, copyFile } from 'fs/promises' +import { fileExists } from './fileExists.js' const currentDir = process.cwd() const config = await getConfig(`${currentDir}/pf-docs.config.mjs`) @@ -86,6 +87,26 @@ async function transformMDContentToMDX() { } } +async function initializeApiIndex() { + const templateIndexPath = join(astroRoot, 'cli', 'templates', 'apiIndex.json') + const targetIndexPath = join(astroRoot, 'src', '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') + return + } + + try { + await copyFile(templateIndexPath, targetIndexPath) + 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) @@ -103,6 +124,7 @@ async function buildProject(): Promise { return config } + await initializeApiIndex() await transformMDContentToMDX() build({ @@ -172,6 +194,7 @@ program.command('init').action(async () => { program.command('start').action(async () => { await updateContent(program) + await initializeApiIndex() // 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 diff --git a/cli/convertToMDX.ts b/cli/convertToMDX.ts index bdd0163..ed1a808 100644 --- a/cli/convertToMDX.ts +++ b/cli/convertToMDX.ts @@ -1,6 +1,7 @@ -import { readFile, writeFile, access } from 'fs/promises' +import { readFile, writeFile } from 'fs/promises' import { glob } from 'glob' import path from 'path' +import { fileExists } from './fileExists.js' function handleTsExamples(content: string): string { //regex link: https://regexr.com/8f0bu @@ -57,10 +58,6 @@ function convertCommentsToMDX(content: string): string { ) } -async function fileExists(file: string): Promise { - return access(file).then(() => true).catch(() => false) -} - async function processFile(file: string): Promise { const exists = await fileExists(file) diff --git a/cli/createCollectionContent.ts b/cli/createCollectionContent.ts index b5eb2e4..3d0fd73 100644 --- a/cli/createCollectionContent.ts +++ b/cli/createCollectionContent.ts @@ -80,6 +80,8 @@ export async function createCollectionContent( verboseModeLog('repoRootDir: ', repoRootDir, '\n') const contentWithAbsolutePaths = content.map((contentEntry) => { + const version = contentEntry.version || 'v6' + if (contentEntry.base) { const absoluteBase = resolve(configDir, contentEntry.base) @@ -89,6 +91,7 @@ export async function createCollectionContent( return { ...contentEntry, base: absoluteBase, + version } } @@ -103,6 +106,7 @@ export async function createCollectionContent( return { ...contentEntry, base: null, + version } } @@ -116,6 +120,7 @@ export async function createCollectionContent( return { base: packagePath, ...contentEntry, + version } }) diff --git a/cli/fileExists.ts b/cli/fileExists.ts new file mode 100644 index 0000000..dcb40f5 --- /dev/null +++ b/cli/fileExists.ts @@ -0,0 +1,7 @@ +import { access } from 'fs/promises' + +export async function fileExists(file: string): Promise { + return access(file) + .then(() => true) + .catch(() => false) +} diff --git a/cli/getConfig.ts b/cli/getConfig.ts index 25ffe6a..a0b755e 100644 --- a/cli/getConfig.ts +++ b/cli/getConfig.ts @@ -2,6 +2,7 @@ export interface CollectionDefinition { base?: string packageName?: string + version?: string pattern: string name: string } diff --git a/cli/templates/apiIndex.json b/cli/templates/apiIndex.json new file mode 100644 index 0000000..3e881bc --- /dev/null +++ b/cli/templates/apiIndex.json @@ -0,0 +1,6 @@ +{ + "versions": [], + "sections": {}, + "pages": {}, + "tabs": {} +} diff --git a/src/pages/api/[version].ts b/src/pages/api/[version].ts new file mode 100644 index 0000000..f103040 --- /dev/null +++ b/src/pages/api/[version].ts @@ -0,0 +1,25 @@ +import type { APIRoute } from 'astro' +import { createJsonResponse, createIndexKey } from '../../utils/apiHelpers' +import { sections as sectionsData } from '../../apiIndex.json' + +export const prerender = false + +export const GET: APIRoute = async ({ params }) => { + const { version } = params + + if (!version) { + return createJsonResponse( + { error: 'Version parameter is required' }, + 400, + ) + } + + const key = createIndexKey(version) + const sections = sectionsData[key as keyof typeof sectionsData] + + if (!sections) { + return createJsonResponse({ error: `Version '${version}' not found` }, 404) + } + + return createJsonResponse(sections) +} diff --git a/src/pages/api/[version]/[section].ts b/src/pages/api/[version]/[section].ts new file mode 100644 index 0000000..cf35963 --- /dev/null +++ b/src/pages/api/[version]/[section].ts @@ -0,0 +1,28 @@ +import type { APIRoute } from 'astro' +import { createJsonResponse, createIndexKey } from '../../../utils/apiHelpers' +import { pages as pagesData } from '../../../apiIndex.json' + +export const prerender = false + +export const GET: APIRoute = async ({ params }) => { + const { version, section } = params + + if (!version || !section) { + return createJsonResponse( + { error: 'Version and section parameters are required' }, + 400, + ) + } + + const key = createIndexKey(version, section) + const pages = pagesData[key as keyof typeof pagesData] + + if (!pages) { + return createJsonResponse( + { error: `Section '${section}' not found for version '${version}'` }, + 404, + ) + } + + return createJsonResponse(pages) +} diff --git a/src/pages/api/[version]/[section]/[page].ts b/src/pages/api/[version]/[section]/[page].ts new file mode 100644 index 0000000..456e227 --- /dev/null +++ b/src/pages/api/[version]/[section]/[page].ts @@ -0,0 +1,30 @@ +import type { APIRoute } from 'astro' +import { createJsonResponse, createIndexKey } from '../../../../utils/apiHelpers' +import { tabs as tabsData } from '../../../../apiIndex.json' + +export const prerender = false + +export const GET: APIRoute = async ({ params }) => { + const { version, section, page } = params + + if (!version || !section || !page) { + return createJsonResponse( + { error: 'Version, section, and page parameters are required' }, + 400, + ) + } + + const key = createIndexKey(version, section, page) + const tabs = tabsData[key as keyof typeof tabsData] + + if (!tabs) { + return createJsonResponse( + { + error: `Page '${page}' not found in section '${section}' for version '${version}'`, + }, + 404, + ) + } + + return createJsonResponse(tabs) +} diff --git a/src/pages/api/[version]/[section]/[page]/[tab].ts b/src/pages/api/[version]/[section]/[page]/[tab].ts new file mode 100644 index 0000000..ee94428 --- /dev/null +++ b/src/pages/api/[version]/[section]/[page]/[tab].ts @@ -0,0 +1,134 @@ +import type { APIRoute, GetStaticPaths } from 'astro' +import type { CollectionEntry, CollectionKey } from 'astro:content' +import { getCollection } from 'astro:content' +import { content } from '../../../../../content' +import { kebabCase, getDefaultTab, addDemosOrDeprecated } from '../../../../../utils' +import { generateAndWriteApiIndex } from '../../../../../utils/apiIndex/generate' +import { getApiIndex } from '../../../../../utils/apiIndex/get' +import { createJsonResponse, createTextResponse, createIndexKey } from '../../../../../utils/apiHelpers' + +export const prerender = true + +type ContentEntry = CollectionEntry< + 'core-docs' | 'quickstarts-docs' | 'react-component-docs' +> + +export const getStaticPaths: GetStaticPaths = async () => { + // Generate index file for server-side routes to use + // This runs once during build when getCollection() is available + const index = await generateAndWriteApiIndex() + + const paths: { params: { version: string; section: string; page: string; tab: string } }[] = [] + + // Build paths from index structure + for (const version of index.versions) { + for (const section of index.sections[version] || []) { + const sectionKey = createIndexKey(version, section) + for (const page of index.pages[sectionKey] || []) { + const pageKey = createIndexKey(version, section, page) + for (const tab of index.tabs[pageKey] || []) { + paths.push({ params: { version, section, page, tab } }) + } + } + } + } + + return paths +} + +export const GET: APIRoute = async ({ params }) => { + const { version, section, page, tab } = params + + if (!version || !section || !page || !tab) { + return createJsonResponse( + { error: 'Version, section, page, and tab parameters are required' }, + 400, + ) + } + + // Validate using index first (fast path for 404s) + const index = await getApiIndex() + + // Check if version exists + if (!index.versions.includes(version)) { + return createJsonResponse({ error: `Version '${version}' not found` }, 404) + } + + // Check if section exists for this version + const sectionKey = createIndexKey(version, section) + if (!index.sections[version]?.includes(section)) { + return createJsonResponse( + { error: `Section '${section}' not found for version '${version}'` }, + 404, + ) + } + + // Check if page exists for this section + const pageKey = createIndexKey(version, section, page) + if (!index.pages[sectionKey]?.includes(page)) { + return createJsonResponse( + { error: `Page '${page}' not found in section '${section}' for version '${version}'` }, + 404, + ) + } + + // Check if tab exists for this page + if (!index.tabs[pageKey]?.includes(tab)) { + return createJsonResponse( + { + error: `Tab '${tab}' not found for page '${page}' in section '${section}' for version '${version}'`, + }, + 404, + ) + } + + // Path is valid, now fetch the actual content + const collectionsToFetch = content + .filter((entry) => entry.version === version) + .map((entry) => entry.name as CollectionKey) + + const collections = await Promise.all( + collectionsToFetch.map((name) => getCollection(name)), + ) + + const flatEntries = collections + .flat() + .map(({ data, filePath, ...rest }) => ({ + filePath, + ...rest, + data: { + ...data, + tab: data.tab || data.source || getDefaultTab(filePath), + }, + })) + + // Find the matching entry + const matchingEntry = flatEntries.find((entry: ContentEntry) => { + const entryTab = addDemosOrDeprecated(entry.data.tab, entry.id) + return ( + entry.data.section === section && + kebabCase(entry.data.id) === page && + entryTab === tab + ) + }) + + // This shouldn't happen since we validated with index, but handle it anyway + if (!matchingEntry) { + // Log warning - indicates index/content mismatch + console.warn( + `[API Warning] Index exists but content not found: ${version}/${section}/${page}/${tab}. ` + + 'This may indicate a mismatch between index generation and actual content.' + ) + return createJsonResponse( + { + error: `Content not found for tab '${tab}' in page '${page}', section '${section}', version '${version}'`, + }, + 404, + ) + } + + // Get the raw body content (markdown/mdx text) + const textContent = matchingEntry.body || '' + + return createTextResponse(textContent) +} diff --git a/src/pages/api/__tests__/[version].test.ts b/src/pages/api/__tests__/[version].test.ts new file mode 100644 index 0000000..cb2a7f4 --- /dev/null +++ b/src/pages/api/__tests__/[version].test.ts @@ -0,0 +1,92 @@ +import { GET } from '../[version]' + +/** + * Mock apiIndex.json with multiple versions (v5, v6) + * to test section retrieval for different versions + */ +jest.mock('../../../apiIndex.json', () => ({ + versions: ['v5', 'v6'], + sections: { + v5: ['getting-started'], + v6: ['components', 'layouts', 'utilities'], + }, + pages: {}, + tabs: {}, +})) + +it('returns all sections for a valid version', async () => { + const response = await GET({ + params: { version: 'v6' }, + } 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(Array.isArray(body)).toBe(true) + expect(body).toContain('components') + expect(body).toContain('layouts') + expect(body).toContain('utilities') +}) + +it('returns only sections for the requested version', async () => { + const response = await GET({ + params: { version: 'v5' }, + } as any) + const body = await response.json() + + expect(response.status).toBe(200) + expect(body).toContain('getting-started') +}) + +it('sorts sections alphabetically', async () => { + const response = await GET({ + params: { version: 'v6' }, + } as any) + const body = await response.json() + + const sorted = [...body].sort() + expect(body).toEqual(sorted) +}) + +it('deduplicates sections from multiple collections', async () => { + const response = await GET({ + params: { version: 'v6' }, + } as any) + const body = await response.json() + + const unique = [...new Set(body)] + expect(body).toEqual(unique) +}) + +it('returns 404 error for nonexistent version', async () => { + const response = await GET({ + params: { version: 'v99' }, + } as any) + const body = await response.json() + + expect(response.status).toBe(404) + expect(body).toHaveProperty('error') + expect(body.error).toContain('v99') + expect(body.error).toContain('not found') +}) + +it('returns 400 error when version parameter is missing', async () => { + const response = await GET({ + params: {}, + } as any) + const body = await response.json() + + expect(response.status).toBe(400) + expect(body).toHaveProperty('error') + expect(body.error).toContain('Version parameter is required') +}) + +it('excludes content entries that have no section field', async () => { + const response = await GET({ + params: { version: 'v6' }, + } as any) + const body = await response.json() + + // Should only include sections from entries that have data.section + expect(body.length).toBeGreaterThan(0) +}) diff --git a/src/pages/api/__tests__/[version]/[section]/[page]/[tab].test.ts b/src/pages/api/__tests__/[version]/[section]/[page]/[tab].test.ts new file mode 100644 index 0000000..02b61df --- /dev/null +++ b/src/pages/api/__tests__/[version]/[section]/[page]/[tab].test.ts @@ -0,0 +1,192 @@ +import { GET } from '../../../../[version]/[section]/[page]/[tab]' + +/** + * Mock content collections with entries that have body content + * to test markdown/MDX content retrieval + */ +jest.mock('../../../../../../content', () => { + const { mockContentCollections } = jest.requireActual('../../../testHelpers') + return { content: mockContentCollections.v6 } +}) + +/** + * Mock getCollection to return entries with body (markdown content) + * simulating real documentation pages with content + */ +jest.mock('astro:content', () => { + const { mockEntriesWithBody, createGetCollectionMock } = jest.requireActual( + '../../../testHelpers', + ) + return { + getCollection: createGetCollectionMock({ + 'react-component-docs': mockEntriesWithBody['react-component-docs'], + 'core-docs': mockEntriesWithBody['core-docs'], + }), + } +}) + +/** + * Mock utilities for tab identification and transformation + */ +jest.mock('../../../../../../utils', () => { + const { mockUtils } = jest.requireActual('../../../testHelpers') + return mockUtils +}) + +/** + * Mock API index to validate paths + */ +jest.mock('../../../../../../utils/apiIndex/get', () => ({ + getApiIndex: jest.fn().mockResolvedValue({ + versions: ['v6'], + sections: { + v6: ['components'], + }, + pages: { + 'v6::components': ['alert'], + }, + tabs: { + 'v6::components::alert': ['react', 'html', 'react-demos'], + }, + }), +})) + +beforeEach(() => { + jest.clearAllMocks() +}) + +it('returns markdown/MDX content as plain text', async () => { + const response = await GET({ + params: { + version: 'v6', + section: 'components', + page: 'alert', + tab: 'react', + }, + } as any) + const body = await response.text() + + expect(response.status).toBe(200) + expect(response.headers.get('Content-Type')).toBe('text/plain; charset=utf-8') + expect(typeof body).toBe('string') + expect(body).toContain('Alert Component') +}) + +it('returns different content for different tabs', async () => { + const reactResponse = await GET({ + params: { + version: 'v6', + section: 'components', + page: 'alert', + tab: 'react', + }, + } as any) + const reactBody = await reactResponse.text() + + const htmlResponse = await GET({ + params: { + version: 'v6', + section: 'components', + page: 'alert', + tab: 'html', + }, + } as any) + const htmlBody = await htmlResponse.text() + + expect(reactBody).toContain('React Alert') + expect(htmlBody).toContain('HTML') + expect(reactBody).not.toEqual(htmlBody) +}) + +it('returns demo content for demos tabs', async () => { + const response = await GET({ + params: { + version: 'v6', + section: 'components', + page: 'alert', + tab: 'react-demos', + }, + } as any) + const body = await response.text() + + expect(response.status).toBe(200) + expect(body).toContain('demos') +}) + +it('returns 404 error for nonexistent version', async () => { + const response = await GET({ + params: { + version: 'v99', + section: 'components', + page: 'alert', + tab: 'react', + }, + } as any) + const body = await response.json() + + expect(response.status).toBe(404) + expect(body).toHaveProperty('error') + expect(body.error).toContain('v99') +}) + +it('returns 404 error for nonexistent section', async () => { + const response = await GET({ + params: { + version: 'v6', + section: 'invalid', + page: 'alert', + tab: 'react', + }, + } as any) + const body = await response.json() + + expect(response.status).toBe(404) + expect(body).toHaveProperty('error') +}) + +it('returns 404 error for nonexistent page', async () => { + const response = await GET({ + params: { + version: 'v6', + section: 'components', + page: 'nonexistent', + tab: 'react', + }, + } as any) + const body = await response.json() + + expect(response.status).toBe(404) + expect(body).toHaveProperty('error') + expect(body.error).toContain('nonexistent') +}) + +it('returns 404 error for nonexistent tab', async () => { + const response = await GET({ + params: { + version: 'v6', + section: 'components', + page: 'alert', + tab: 'nonexistent', + }, + } as any) + const body = await response.json() + + expect(response.status).toBe(404) + expect(body).toHaveProperty('error') + expect(body.error).toContain('nonexistent') +}) + +it('returns 400 error when required parameters are missing', async () => { + const response = await GET({ + params: { + version: 'v6', + section: 'components', + page: 'alert', + }, + } as any) + const body = await response.json() + + expect(response.status).toBe(400) + expect(body).toHaveProperty('error') + expect(body.error).toContain('required') +}) diff --git a/src/pages/api/__tests__/versions.test.ts b/src/pages/api/__tests__/versions.test.ts new file mode 100644 index 0000000..7948e7b --- /dev/null +++ b/src/pages/api/__tests__/versions.test.ts @@ -0,0 +1,36 @@ +import { GET } from '../versions' + +/** + * Mock apiIndex.json with multiple versions + */ +jest.mock('../../../apiIndex.json', () => ({ + versions: ['v5', 'v6'], + sections: {}, + pages: {}, + tabs: {}, +})) + +it('returns unique versions as sorted array', async () => { + const response = await GET({} 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']) +}) + +it('sorts versions alphabetically', async () => { + const response = await GET({} as any) + const body = await response.json() + + expect(body).toEqual(['v5', 'v6']) +}) + +it('returns only the versions from the index', async () => { + const response = await GET({} 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) +}) diff --git a/src/pages/api/index.ts b/src/pages/api/index.ts new file mode 100644 index 0000000..e9a2496 --- /dev/null +++ b/src/pages/api/index.ts @@ -0,0 +1,172 @@ +import type { APIRoute } from 'astro' +import { createJsonResponse } from '../../utils/apiHelpers' + +export const prerender = false + +export const GET: APIRoute = async () => + createJsonResponse({ + name: 'PatternFly Documentation API', + description: 'Machine-readable documentation API for LLM agents and MCP servers', + version: '1.0.0', + baseUrl: '/text', + endpoints: [ + { + path: '/text', + method: 'GET', + description: 'Get API schema and documentation', + returns: { + type: 'object', + description: 'API schema with endpoints and usage information', + }, + }, + { + path: '/text/versions', + method: 'GET', + description: 'List available documentation versions', + returns: { + type: 'array', + items: 'string', + example: ['v6'], + }, + }, + { + path: '/text/openapi.json', + method: 'GET', + description: 'Get OpenAPI 3.0 specification', + returns: { + type: 'object', + description: 'Full OpenAPI 3.0 specification for this API', + }, + }, + { + path: '/text/{version}', + method: 'GET', + description: 'List available sections for a specific version', + parameters: [ + { + name: 'version', + in: 'path', + required: true, + type: 'string', + example: 'v6', + }, + ], + returns: { + type: 'array', + items: 'string', + example: ['components', 'layouts', 'utilities'], + }, + }, + { + path: '/text/{version}/{section}', + method: 'GET', + description: 'List available pages within a section', + parameters: [ + { + name: 'version', + in: 'path', + required: true, + type: 'string', + example: 'v6', + }, + { + name: 'section', + in: 'path', + required: true, + type: 'string', + example: 'components', + }, + ], + returns: { + type: 'array', + items: 'string', + description: 'Array of kebab-cased page IDs', + example: ['alert', 'button', 'card'], + }, + }, + { + path: '/text/{version}/{section}/{page}', + method: 'GET', + description: 'List available tabs for a page', + parameters: [ + { + name: 'version', + in: 'path', + required: true, + type: 'string', + example: 'v6', + }, + { + name: 'section', + in: 'path', + required: true, + type: 'string', + example: 'components', + }, + { + name: 'page', + in: 'path', + required: true, + type: 'string', + example: 'alert', + }, + ], + returns: { + type: 'array', + items: 'string', + description: 'Array of tab slugs', + example: ['react', 'react-demos', 'html'], + }, + }, + { + path: '/text/{version}/{section}/{page}/{tab}', + method: 'GET', + description: 'Get raw markdown/MDX content for a specific tab', + parameters: [ + { + name: 'version', + in: 'path', + required: true, + type: 'string', + example: 'v6', + }, + { + name: 'section', + in: 'path', + required: true, + type: 'string', + example: 'components', + }, + { + name: 'page', + in: 'path', + required: true, + type: 'string', + example: 'alert', + }, + { + name: 'tab', + in: 'path', + required: true, + type: 'string', + example: 'react', + }, + ], + returns: { + type: 'string', + contentType: 'text/plain; charset=utf-8', + description: 'Raw markdown/MDX documentation content', + }, + }, + ], + usage: { + description: 'Navigate the API hierarchically to discover and retrieve documentation', + exampleFlow: [ + 'GET /text/versions → ["v6"]', + 'GET /text/v6 → ["components", "layouts", ...]', + 'GET /text/v6/components → ["alert", "button", ...]', + 'GET /text/v6/components/alert → ["react", "html", ...]', + 'GET /text/v6/components/alert/react → (markdown content)', + ], + }, + }) \ No newline at end of file diff --git a/src/pages/api/openapi.json.ts b/src/pages/api/openapi.json.ts new file mode 100644 index 0000000..af94756 --- /dev/null +++ b/src/pages/api/openapi.json.ts @@ -0,0 +1,387 @@ +import type { APIRoute } from 'astro' +import index from '../../apiIndex.json' + +export const prerender = false + +export const GET: APIRoute = async () => { + const versions = index.versions + + const openApiSpec = { + openapi: '3.0.0', + info: { + title: 'PatternFly Documentation API', + description: + 'Machine-readable documentation API for LLM agents and MCP servers. Provides hierarchical access to PatternFly documentation content.', + version: '1.0.0', + contact: { + name: 'PatternFly', + url: 'https://patternfly.org', + }, + }, + servers: [ + { + url: '/api', + description: 'Documentation API base path', + }, + ], + paths: { + '/': { + get: { + summary: 'Get API documentation', + description: + 'Returns self-documenting API schema with complete endpoint descriptions, parameters, and usage examples', + operationId: 'getApiDocs', + responses: { + '200': { + description: 'API documentation schema', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + name: { type: 'string' }, + description: { type: 'string' }, + version: { type: 'string' }, + baseUrl: { type: 'string' }, + endpoints: { + type: 'array', + items: { type: 'object' }, + }, + usage: { type: 'object' }, + }, + }, + }, + }, + }, + }, + }, + }, + '/versions': { + get: { + summary: 'List available versions', + description: 'Returns an array of available documentation versions', + operationId: 'getVersions', + responses: { + '200': { + description: 'List of available versions', + content: { + 'application/json': { + schema: { + type: 'array', + items: { + type: 'string', + }, + example: versions, + }, + }, + }, + }, + }, + }, + }, + '/openapi.json': { + get: { + summary: 'Get OpenAPI specification', + description: 'Returns the complete OpenAPI 3.0 specification for this API', + operationId: 'getOpenApiSpec', + responses: { + '200': { + description: 'OpenAPI 3.0 specification', + content: { + 'application/json': { + schema: { + type: 'object', + description: 'Full OpenAPI 3.0 specification', + }, + }, + }, + }, + }, + }, + }, + '/{version}': { + get: { + summary: 'List sections for a version', + description: + 'Returns an array of available documentation sections for the specified version', + operationId: 'getSections', + parameters: [ + { + name: 'version', + in: 'path', + required: true, + description: 'Documentation version', + schema: { + type: 'string', + enum: versions, + }, + example: 'v6', + }, + ], + responses: { + '200': { + description: 'List of available sections', + content: { + 'application/json': { + schema: { + type: 'array', + items: { + type: 'string', + }, + }, + example: ['components', 'layouts', 'utilities'], + }, + }, + }, + '404': { + description: 'Version not found', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + error: { + type: 'string', + }, + }, + }, + }, + }, + }, + }, + }, + }, + '/{version}/{section}': { + get: { + summary: 'List pages in a section', + description: + 'Returns an array of page IDs within the specified section', + operationId: 'getPages', + parameters: [ + { + name: 'version', + in: 'path', + required: true, + description: 'Documentation version', + schema: { + type: 'string', + enum: versions, + }, + example: 'v6', + }, + { + name: 'section', + in: 'path', + required: true, + description: 'Documentation section', + schema: { + type: 'string', + }, + example: 'components', + }, + ], + responses: { + '200': { + description: 'List of page IDs (kebab-cased)', + content: { + 'application/json': { + schema: { + type: 'array', + items: { + type: 'string', + }, + }, + example: ['alert', 'button', 'card'], + }, + }, + }, + '404': { + description: 'Section not found', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + error: { + type: 'string', + }, + }, + }, + }, + }, + }, + }, + }, + }, + '/{version}/{section}/{page}': { + get: { + summary: 'List tabs for a page', + description: + 'Returns an array of available tab slugs for the specified page', + operationId: 'getTabs', + parameters: [ + { + name: 'version', + in: 'path', + required: true, + description: 'Documentation version', + schema: { + type: 'string', + enum: versions, + }, + example: 'v6', + }, + { + name: 'section', + in: 'path', + required: true, + description: 'Documentation section', + schema: { + type: 'string', + }, + example: 'components', + }, + { + name: 'page', + in: 'path', + required: true, + description: 'Page ID (kebab-cased)', + schema: { + type: 'string', + }, + example: 'alert', + }, + ], + responses: { + '200': { + description: 'List of available tab slugs', + content: { + 'application/json': { + schema: { + type: 'array', + items: { + type: 'string', + }, + }, + example: ['react', 'react-demos', 'html'], + }, + }, + }, + '404': { + description: 'Page not found', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + error: { + type: 'string', + }, + }, + }, + }, + }, + }, + }, + }, + }, + '/{version}/{section}/{page}/{tab}': { + get: { + summary: 'Get tab content', + description: + 'Returns the raw markdown/MDX documentation content for the specified tab', + operationId: 'getContent', + parameters: [ + { + name: 'version', + in: 'path', + required: true, + description: 'Documentation version', + schema: { + type: 'string', + enum: versions, + }, + example: 'v6', + }, + { + name: 'section', + in: 'path', + required: true, + description: 'Documentation section', + schema: { + type: 'string', + }, + example: 'components', + }, + { + name: 'page', + in: 'path', + required: true, + description: 'Page ID (kebab-cased)', + schema: { + type: 'string', + }, + example: 'alert', + }, + { + name: 'tab', + in: 'path', + required: true, + description: 'Tab slug', + schema: { + type: 'string', + }, + example: 'react', + }, + ], + responses: { + '200': { + description: 'Raw markdown/MDX content', + content: { + 'text/plain; charset=utf-8': { + schema: { + type: 'string', + }, + example: + '---\ntitle: Alert\nsection: components\n---\n\n## Overview\n\nAn alert is a notification that provides...', + }, + }, + }, + '404': { + description: 'Tab not found', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + error: { + type: 'string', + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + tags: [ + { + name: 'Documentation', + description: 'PatternFly documentation endpoints', + }, + ], + } + + const body = JSON.stringify(openApiSpec, null, 2) + return new Response(body, { + status: 200, + headers: { + 'Content-Type': 'application/json; charset=utf-8', + 'Cache-Control': 'no-cache', + Date: new Date().toUTCString(), + 'Content-Length': body.length.toString(), + }, + }) +} diff --git a/src/pages/api/versions.ts b/src/pages/api/versions.ts new file mode 100644 index 0000000..64e53f5 --- /dev/null +++ b/src/pages/api/versions.ts @@ -0,0 +1,11 @@ +import type { APIRoute } from 'astro' +import { createJsonResponse } from '../../utils/apiHelpers' +import { versions as versionsData } from '../../apiIndex.json' + +export const prerender = false + +export const GET: APIRoute = async () => { + const versions = versionsData + + return createJsonResponse(versions) +} diff --git a/src/utils/__tests__/apiHelpers.test.ts b/src/utils/__tests__/apiHelpers.test.ts new file mode 100644 index 0000000..b160bcf --- /dev/null +++ b/src/utils/__tests__/apiHelpers.test.ts @@ -0,0 +1,231 @@ +import { createJsonResponse, createTextResponse, createIndexKey } from '../apiHelpers' + +describe('createJsonResponse', () => { + it('returns a Response with status 200 by default', () => { + const response = createJsonResponse({ message: 'success' }) + expect(response.status).toBe(200) + }) + + it('returns a Response with custom status code', () => { + const response = createJsonResponse({ error: 'not found' }, 404) + expect(response.status).toBe(404) + }) + + it('sets correct Content-Type header with charset', () => { + const response = createJsonResponse({ test: 'data' }) + expect(response.headers.get('Content-Type')).toBe( + 'application/json; charset=utf-8', + ) + }) + + it('sets Cache-Control header to no-cache', () => { + const response = createJsonResponse({ test: 'data' }) + expect(response.headers.get('Cache-Control')).toBe('no-cache') + }) + + it('sets Date header', () => { + const response = createJsonResponse({ test: 'data' }) + const dateHeader = response.headers.get('Date') + expect(dateHeader).toBeTruthy() + // Verify it's a valid date string + expect(new Date(dateHeader!).toString()).not.toBe('Invalid Date') + }) + + it('sets Content-Length header to match body length', async () => { + const data = { message: 'test', count: 42 } + const response = createJsonResponse(data) + const body = await response.text() + expect(response.headers.get('Content-Length')).toBe(body.length.toString()) + }) + + it('correctly serializes an object', async () => { + const data = { message: 'hello', nested: { value: 123 } } + const response = createJsonResponse(data) + const body = await response.json() + expect(body).toEqual(data) + }) + + it('correctly serializes an array', async () => { + const data = ['one', 'two', 'three'] + const response = createJsonResponse(data) + const body = await response.json() + expect(body).toEqual(data) + }) + + it('correctly serializes a string', async () => { + const data = 'plain string' + const response = createJsonResponse(data) + const body = await response.json() + expect(body).toBe(data) + }) + + it('correctly serializes null', async () => { + const response = createJsonResponse(null) + const body = await response.json() + expect(body).toBeNull() + }) + + it('correctly serializes an empty object', async () => { + const response = createJsonResponse({}) + const body = await response.json() + expect(body).toEqual({}) + }) + + it('correctly serializes an empty array', async () => { + const response = createJsonResponse([]) + const body = await response.json() + expect(body).toEqual([]) + }) + + it('handles complex nested data structures', async () => { + const data = { + users: [ + { id: 1, name: 'Alice', tags: ['admin', 'user'] }, + { id: 2, name: 'Bob', tags: ['user'] }, + ], + meta: { total: 2, page: 1 }, + } + const response = createJsonResponse(data) + const body = await response.json() + expect(body).toEqual(data) + }) + + it('creates error responses with proper status codes', async () => { + const errorData = { error: 'Bad Request', message: 'Invalid input' } + const response = createJsonResponse(errorData, 400) + expect(response.status).toBe(400) + const body = await response.json() + expect(body).toEqual(errorData) + }) +}) + +describe('createTextResponse', () => { + it('returns a Response with status 200 by default', () => { + const response = createTextResponse('Hello, world!') + expect(response.status).toBe(200) + }) + + it('returns a Response with custom status code', () => { + const response = createTextResponse('Not found', 404) + expect(response.status).toBe(404) + }) + + it('sets correct Content-Type header with charset', () => { + const response = createTextResponse('test content') + expect(response.headers.get('Content-Type')).toBe( + 'text/plain; charset=utf-8', + ) + }) + + it('sets Cache-Control header to no-cache', () => { + const response = createTextResponse('test content') + expect(response.headers.get('Cache-Control')).toBe('no-cache') + }) + + it('sets Date header', () => { + const response = createTextResponse('test content') + const dateHeader = response.headers.get('Date') + expect(dateHeader).toBeTruthy() + // Verify it's a valid date string + expect(new Date(dateHeader!).toString()).not.toBe('Invalid Date') + }) + + it('sets Content-Length header to match content length', () => { + const content = 'This is test content' + const response = createTextResponse(content) + expect(response.headers.get('Content-Length')).toBe( + content.length.toString(), + ) + }) + + it('returns correct text content', async () => { + const content = 'Hello, world!' + const response = createTextResponse(content) + const body = await response.text() + expect(body).toBe(content) + }) + + it('handles empty string', async () => { + const response = createTextResponse('') + const body = await response.text() + expect(body).toBe('') + // Content-Length is omitted when length is 0 + expect(response.headers.get('Content-Length')).toBeNull() + }) + + it('handles multiline text', async () => { + const content = 'Line 1\nLine 2\nLine 3' + const response = createTextResponse(content) + const body = await response.text() + expect(body).toBe(content) + }) + + it('handles unicode characters', async () => { + const content = 'Hello 世界 🌍' + const response = createTextResponse(content) + const body = await response.text() + expect(body).toBe(content) + }) + + it('handles markdown content', async () => { + const markdown = `# Heading + +## Subheading + +- Item 1 +- Item 2 + +\`\`\`javascript +const foo = 'bar'; +\`\`\` +` + const response = createTextResponse(markdown) + const body = await response.text() + expect(body).toBe(markdown) + }) + + it('creates error responses with proper status codes', async () => { + const errorText = 'Internal Server Error' + const response = createTextResponse(errorText, 500) + expect(response.status).toBe(500) + const body = await response.text() + expect(body).toBe(errorText) + }) +}) + +describe('createIndexKey', () => { + it('creates a key from a single part', () => { + const key = createIndexKey('v6') + expect(key).toBe('v6') + }) + + it('creates a key from two parts', () => { + const key = createIndexKey('v6', 'components') + expect(key).toBe('v6::components') + }) + + it('creates a key from three parts', () => { + const key = createIndexKey('v6', 'components', 'alert') + expect(key).toBe('v6::components::alert') + }) + + it('creates a key from four parts', () => { + const key = createIndexKey('v6', 'components', 'alert', 'react') + expect(key).toBe('v6::components::alert::react') + }) + + it('handles empty string parts', () => { + const key = createIndexKey('v6', '', 'alert') + expect(key).toBe('v6::::alert') + }) + + it('handles special characters in parts', () => { + const key = createIndexKey('v6', 'my-section', 'my_page') + expect(key).toBe('v6::my-section::my_page') + }) + + it('returns empty string when called with no arguments', () => { + const key = createIndexKey() + expect(key).toBe('') + }) +}) diff --git a/src/utils/apiHelpers.ts b/src/utils/apiHelpers.ts new file mode 100644 index 0000000..8fa9c07 --- /dev/null +++ b/src/utils/apiHelpers.ts @@ -0,0 +1,50 @@ +function getHeaders( + type: 'application/json' | 'text/plain', + contentLength?: number, +): HeadersInit { + const headers: HeadersInit = { + 'Content-Type': `${type}; charset=utf-8`, + 'Cache-Control': 'no-cache', + Date: new Date().toUTCString(), + } + + if (contentLength) { + headers['Content-Length'] = contentLength.toString() + } + + return headers +} + +export function createJsonResponse( + data: unknown, + status: number = 200, +): Response { + const body = JSON.stringify(data) + return new Response(body, { + status, + headers: getHeaders('application/json', body.length), + }) +} + +export function createTextResponse( + content: string, + status: number = 200, +): Response { + return new Response(content, { + status, + headers: getHeaders('text/plain', content.length), + }) +} + +/** + * Creates an index key by joining parts with '::' separator + * Used to construct keys for looking up sections, pages, and tabs in the API index + * + * @example + * createIndexKey('v6') // 'v6' + * createIndexKey('v6', 'components') // 'v6::components' + * createIndexKey('v6', 'components', 'alert') // 'v6::components::alert' + */ +export function createIndexKey(...parts: string[]): string { + return parts.join('::') +} diff --git a/src/utils/apiIndex/generate.ts b/src/utils/apiIndex/generate.ts new file mode 100644 index 0000000..3fd6efb --- /dev/null +++ b/src/utils/apiIndex/generate.ts @@ -0,0 +1,157 @@ +/* eslint-disable no-console */ +import { join } from 'path' +import { writeFile } from 'fs/promises' +import { getCollection } from 'astro:content' +import type { CollectionKey } from 'astro:content' +import { content } from '../../content' +import { kebabCase, getDefaultTab, addDemosOrDeprecated } from '../index' + +const SOURCE_ORDER: Record = { + react: 1, + 'react-next': 1.1, + 'react-demos': 2, + 'react-deprecated': 2.1, + html: 3, + 'html-demos': 4, + 'design-guidelines': 99, + accessibility: 100, + 'upgrade-guide': 101, + 'release-notes': 102, +} + +const sortSources = (s1: string, s2: string) => { + const s1Index = SOURCE_ORDER[s1] || 50 + const s2Index = SOURCE_ORDER[s2] || 50 + if (s1Index === 50 && s2Index === 50) { + return s1.localeCompare(s2) + } + return s1Index > s2Index ? 1 : -1 +} + +/** + * Structure of the API index used for routing and navigation + * Keys in sections/pages/tabs use '::' separator (e.g., 'v6::components::alert') + */ +export interface ApiIndex { + /** Available documentation versions (e.g., ['v5', 'v6']) */ + versions: string[] + /** Sections by version (e.g., { 'v6': ['components', 'layouts'] }) */ + sections: Record + /** Pages by version::section (e.g., { 'v6::components': ['alert', 'button'] }) */ + pages: Record + /** Tabs by version::section::page (e.g., { 'v6::components::alert': ['react', 'html'] }) */ + tabs: Record +} + +/** + * Generates API index by analyzing all content collections + * Extracts versions, sections, pages, and tabs into a hierarchical structure + * This runs during build time when getCollection() is available + * + * @returns Promise resolving to complete API index structure + */ +export async function generateApiIndex(): Promise { + console.log('Generating API index from content collections...') + + const index: ApiIndex = { + versions: [], + sections: {}, + pages: {}, + tabs: {}, + } + + // Get all versions + const versionSet = new Set() + content.forEach((entry: any) => { + if (entry.version) { + versionSet.add(entry.version) + } + }) + index.versions = Array.from(versionSet).sort() + + // For each version, collect index data + for (const version of versionSet) { + const collectionsToFetch = content + .filter((entry: any) => entry.version === version) + .map((entry: any) => entry.name as CollectionKey) + + const collections = await Promise.all( + collectionsToFetch.map((name) => getCollection(name)), + ) + + const flatEntries = collections.flat() + + // Collect sections, pages, and tabs in a single pass + const sections = new Set() + const sectionPages: Record> = {} + const pageTabs: Record> = {} + + flatEntries.forEach((entry: any) => { + if (!entry.data.section) return + + const section = entry.data.section + const page = kebabCase(entry.data.id) + const sectionKey = `${version}::${section}` + const pageKey = `${version}::${section}::${page}` + + // Collect section + sections.add(section) + + // Collect page + if (!sectionPages[sectionKey]) { + sectionPages[sectionKey] = new Set() + } + sectionPages[sectionKey].add(page) + + // Collect tab + const entryTab = entry.data.tab || entry.data.source || getDefaultTab(entry.filePath) + const tab = addDemosOrDeprecated(entryTab, entry.id) + if (!pageTabs[pageKey]) { + pageTabs[pageKey] = new Set() + } + pageTabs[pageKey].add(tab) + }) + + // Convert sets to sorted arrays + index.sections[version] = Array.from(sections).sort() + + Object.entries(sectionPages).forEach(([key, pages]) => { + index.pages[key] = Array.from(pages).sort() + }) + + Object.entries(pageTabs).forEach(([key, tabs]) => { + index.tabs[key] = Array.from(tabs).sort(sortSources) + }) + } + + return index +} + +/** + * Writes API index to src/apiIndex.json + * 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') + + try { + await writeFile(indexPath, JSON.stringify(index, null, 2)) + console.log(`✓ Generated API index with ${index.versions.length} versions`) + } catch (error) { + console.warn('Warning: Could not write API index file:', error) + } +} + +/** + * Generates and writes API index in a single operation + * Called during build in getStaticPaths to ensure index exists for server routes + * + * @returns Promise resolving to the generated API index + */ +export async function generateAndWriteApiIndex(): Promise { + const index = await generateApiIndex() + await writeApiIndex(index) + return index +} diff --git a/src/utils/apiIndex/get.ts b/src/utils/apiIndex/get.ts new file mode 100644 index 0000000..1f42458 --- /dev/null +++ b/src/utils/apiIndex/get.ts @@ -0,0 +1,92 @@ +import { join } from 'path' +import { readFile } from 'fs/promises' +import type { ApiIndex } from './generate' + +/** + * Reads and parses the API index file + * Validates the structure and provides helpful error messages if file is missing or invalid + * + * @returns Promise resolving to the API index structure + * @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') + + try { + const content = await readFile(indexPath, 'utf-8') + const parsed = JSON.parse(content) + + // Validate index structure + if (!parsed.versions || !Array.isArray(parsed.versions)) { + throw new Error('Invalid API index structure: missing or invalid "versions" array') + } + + return parsed as ApiIndex + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + throw new Error( + `API index file not found at ${indexPath}. ` + + 'Please run the build process to generate the index.' + ) + } + + if (error instanceof SyntaxError) { + throw new Error( + `API index file contains invalid JSON at ${indexPath}. ` + + 'Please rebuild to regenerate the index file.' + ) + } + + throw error + } +} + +/** + * Gets all available documentation versions + * + * @returns Promise resolving to array of version strings (e.g., ['v5', 'v6']) + */ +export async function getVersions(): Promise { + const index = await getApiIndex() + return index.versions +} + +/** + * Gets all sections for a specific version + * + * @param version - The documentation version (e.g., 'v6') + * @returns Promise resolving to array of section names, or empty array if version not found + */ +export async function getSections(version: string): Promise { + const index = await getApiIndex() + return index.sections[version] || [] +} + +/** + * Gets all pages within a section for a specific version + * + * @param version - The documentation version (e.g., 'v6') + * @param section - The section name (e.g., 'components') + * @returns Promise resolving to array of page slugs, or empty array if not found + */ +export async function getPages(version: string, section: string): Promise { + const index = await getApiIndex() + const { createIndexKey } = await import('../apiHelpers') + const key = createIndexKey(version, section) + return index.pages[key] || [] +} + +/** + * Gets all tabs for a specific page + * + * @param version - The documentation version (e.g., 'v6') + * @param section - The section name (e.g., 'components') + * @param page - The page slug (e.g., 'alert') + * @returns Promise resolving to array of tab names, or empty array if not found + */ +export async function getTabs(version: string, section: string, page: string): Promise { + const index = await getApiIndex() + const { createIndexKey } = await import('../apiHelpers') + const key = createIndexKey(version, section, page) + return index.tabs[key] || [] +} diff --git a/src/utils/apiIndex/index.ts b/src/utils/apiIndex/index.ts new file mode 100644 index 0000000..5531440 --- /dev/null +++ b/src/utils/apiIndex/index.ts @@ -0,0 +1,8 @@ +export { + generateApiIndex, + writeApiIndex, + generateAndWriteApiIndex, + type ApiIndex, +} from './generate' + +export { getApiIndex, getVersions, getSections, getPages, getTabs } from './get' From 216a71055a1b89f9dbd79e41a72c1d288d343205 Mon Sep 17 00:00:00 2001 From: Austin Sullivan Date: Thu, 4 Dec 2025 11:53:15 -0500 Subject: [PATCH 2/6] fix cloudflare excluding api routes from worker bundle --- astro.config.mjs | 1 - package.json | 4 ++-- public/_routes.json | 22 ++++++++++++++++++++++ wrangler.jsonc | 10 +++------- 4 files changed, 27 insertions(+), 10 deletions(-) create mode 100644 public/_routes.json diff --git a/astro.config.mjs b/astro.config.mjs index e1d3140..4a9622f 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -19,6 +19,5 @@ export default defineConfig({ } } }, - adapter: cloudflare() }); \ No newline at end of file diff --git a/package.json b/package.json index e266123..3011e88 100644 --- a/package.json +++ b/package.json @@ -16,9 +16,9 @@ "build:cli": "tsc --build ./cli/tsconfig.json", "build:cli:watch": "tsc --build --watch ./cli/tsconfig.json", "build:props": "npm run build:cli && node ./dist/cli/cli.js generate-props", - "preview": "astro build && wrangler pages dev", + "preview": "wrangler pages dev", "astro": "astro", - "deploy": "npm run build && wrangler pages deploy", + "deploy": "wrangler pages deploy", "prettier": "prettier --write ./src", "lint": "eslint . --cache --cache-strategy content", "test": "jest", diff --git a/public/_routes.json b/public/_routes.json new file mode 100644 index 0000000..3490329 --- /dev/null +++ b/public/_routes.json @@ -0,0 +1,22 @@ +{ + "version": 1, + "include": [ + "/*" + ], + "exclude": [ + "/", + "/_astro/*", + "/.assetsignore", + "/PF-HorizontalLogo-Color.svg", + "/PF-HorizontalLogo-Reverse.svg", + "/avatarImg.svg", + "/avatarImgDark.svg", + "/favicon.svg", + "/content/*", + "/components/*", + "/layouts/*", + "/patterns/*", + "/utility-classes/*", + "/extensions/*" + ] +} diff --git a/wrangler.jsonc b/wrangler.jsonc index 781f316..f8e5f43 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -1,14 +1,10 @@ { "$schema": "node_modules/wrangler/config-schema.json", - "name": "patternfly-org", - "main": "./dist/_worker.js/index.js", + "name": "patternfly-docs-core", "compatibility_date": "2025-06-17", "compatibility_flags": ["nodejs_compat"], - "assets": { - "binding": "ASSETS", - "directory": "./dist" - }, "observability": { "enabled": true - } + }, + "pages_build_output_dir": "dist/docs" } From 0fdbcfd0dc5fd5b78f19894ce6f57522de09ac6b Mon Sep 17 00:00:00 2001 From: Austin Sullivan Date: Thu, 4 Dec 2025 13:36:37 -0500 Subject: [PATCH 3/6] fix unit tests for api routes --- cli/__tests__/createCollectionContent.test.ts | 40 +++---- jest.config.ts | 1 + src/__mocks__/astro-content.ts | 6 ++ .../[version]/[section]/[page]/[tab].test.ts | 6 +- src/pages/api/index.ts | 26 ++--- src/pages/api/testHelpers.ts | 102 ++++++++++++++++++ test.setup.ts | 26 +++++ 7 files changed, 173 insertions(+), 34 deletions(-) create mode 100644 src/__mocks__/astro-content.ts create mode 100644 src/pages/api/testHelpers.ts diff --git a/cli/__tests__/createCollectionContent.test.ts b/cli/__tests__/createCollectionContent.test.ts index 4482c85..ee9e066 100644 --- a/cli/__tests__/createCollectionContent.test.ts +++ b/cli/__tests__/createCollectionContent.test.ts @@ -64,7 +64,7 @@ it('should call writeFile with the expected file location and content without th await createCollectionContent('/foo/', '/config/dir/pf-docs.config.mjs', false) const expectedContent = [ - { name: 'test', base: '/config/dir/src/docs', pattern: '**/*.md' } + { name: 'test', base: '/config/dir/src/docs', pattern: '**/*.md', version: 'v6' } ] expect(writeFile).toHaveBeenCalledWith( @@ -208,11 +208,12 @@ it('should handle content with packageName by finding package in node_modules', await createCollectionContent('/foo/', '/config/dir/pf-docs.config.mjs', false) const expectedContent = [ - { + { base: '/config/dir/node_modules/@patternfly/react-core', - name: 'test', - packageName: '@patternfly/react-core', - pattern: '**/*.md' + name: 'test', + packageName: '@patternfly/react-core', + pattern: '**/*.md', + version: 'v6' } ] @@ -242,11 +243,12 @@ it('should handle content with packageName when package is not found locally but await createCollectionContent('/foo/', '/config/dir/pf-docs.config.mjs', false) const expectedContent = [ - { + { base: '/config/node_modules/@patternfly/react-core', - name: 'test', - packageName: '@patternfly/react-core', - pattern: '**/*.md' + name: 'test', + packageName: '@patternfly/react-core', + pattern: '**/*.md', + version: 'v6' } ] @@ -274,11 +276,12 @@ it('should handle content with packageName when package is not found anywhere', await createCollectionContent('/foo/', '/config/dir/pf-docs.config.mjs', false) const expectedContent = [ - { + { base: null, - name: 'test', - packageName: '@patternfly/react-core', - pattern: '**/*.md' + name: 'test', + packageName: '@patternfly/react-core', + pattern: '**/*.md', + version: 'v6' } ] @@ -307,12 +310,13 @@ it('should handle mixed content with both base and packageName entries', async ( await createCollectionContent('/foo/', '/config/dir/pf-docs.config.mjs', false) const expectedContent = [ - { name: 'docs', base: '/config/dir/src/docs', pattern: '**/*.md' }, - { + { name: 'docs', base: '/config/dir/src/docs', pattern: '**/*.md', version: 'v6' }, + { base: '/config/dir/node_modules/@patternfly/react-core', - name: 'components', - packageName: '@patternfly/react-core', - pattern: '**/*.md' + name: 'components', + packageName: '@patternfly/react-core', + pattern: '**/*.md', + version: 'v6' } ] diff --git a/jest.config.ts b/jest.config.ts index b4cdbe0..c98515e 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -17,6 +17,7 @@ const config: Config = { moduleNameMapper: { '\\.(css|less)$': '/src/__mocks__/styleMock.ts', '(.+)\\.js': '$1', + '^astro:content$': '/src/__mocks__/astro-content.ts', }, setupFilesAfterEnv: ['/test.setup.ts'], transformIgnorePatterns: [ diff --git a/src/__mocks__/astro-content.ts b/src/__mocks__/astro-content.ts new file mode 100644 index 0000000..98ca870 --- /dev/null +++ b/src/__mocks__/astro-content.ts @@ -0,0 +1,6 @@ +// Mock for astro:content module +// This is a virtual module in Astro, so we need to provide a mock for Jest + +export const getCollection = jest.fn(); +export const getEntry = jest.fn(); +export const getEntries = jest.fn(); diff --git a/src/pages/api/__tests__/[version]/[section]/[page]/[tab].test.ts b/src/pages/api/__tests__/[version]/[section]/[page]/[tab].test.ts index 02b61df..c3e9867 100644 --- a/src/pages/api/__tests__/[version]/[section]/[page]/[tab].test.ts +++ b/src/pages/api/__tests__/[version]/[section]/[page]/[tab].test.ts @@ -5,7 +5,7 @@ import { GET } from '../../../../[version]/[section]/[page]/[tab]' * to test markdown/MDX content retrieval */ jest.mock('../../../../../../content', () => { - const { mockContentCollections } = jest.requireActual('../../../testHelpers') + const { mockContentCollections } = jest.requireActual('../../../../testHelpers.ts') return { content: mockContentCollections.v6 } }) @@ -15,7 +15,7 @@ jest.mock('../../../../../../content', () => { */ jest.mock('astro:content', () => { const { mockEntriesWithBody, createGetCollectionMock } = jest.requireActual( - '../../../testHelpers', + '../../../../testHelpers.ts', ) return { getCollection: createGetCollectionMock({ @@ -29,7 +29,7 @@ jest.mock('astro:content', () => { * Mock utilities for tab identification and transformation */ jest.mock('../../../../../../utils', () => { - const { mockUtils } = jest.requireActual('../../../testHelpers') + const { mockUtils } = jest.requireActual('../../../../testHelpers.ts') return mockUtils }) diff --git a/src/pages/api/index.ts b/src/pages/api/index.ts index e9a2496..2f1e445 100644 --- a/src/pages/api/index.ts +++ b/src/pages/api/index.ts @@ -8,10 +8,10 @@ export const GET: APIRoute = async () => name: 'PatternFly Documentation API', description: 'Machine-readable documentation API for LLM agents and MCP servers', version: '1.0.0', - baseUrl: '/text', + baseUrl: '/api', endpoints: [ { - path: '/text', + path: '/api', method: 'GET', description: 'Get API schema and documentation', returns: { @@ -20,7 +20,7 @@ export const GET: APIRoute = async () => }, }, { - path: '/text/versions', + path: '/api/versions', method: 'GET', description: 'List available documentation versions', returns: { @@ -30,7 +30,7 @@ export const GET: APIRoute = async () => }, }, { - path: '/text/openapi.json', + path: '/api/openapi.json', method: 'GET', description: 'Get OpenAPI 3.0 specification', returns: { @@ -39,7 +39,7 @@ export const GET: APIRoute = async () => }, }, { - path: '/text/{version}', + path: '/api/{version}', method: 'GET', description: 'List available sections for a specific version', parameters: [ @@ -58,7 +58,7 @@ export const GET: APIRoute = async () => }, }, { - path: '/text/{version}/{section}', + path: '/api/{version}/{section}', method: 'GET', description: 'List available pages within a section', parameters: [ @@ -85,7 +85,7 @@ export const GET: APIRoute = async () => }, }, { - path: '/text/{version}/{section}/{page}', + path: '/api/{version}/{section}/{page}', method: 'GET', description: 'List available tabs for a page', parameters: [ @@ -119,7 +119,7 @@ export const GET: APIRoute = async () => }, }, { - path: '/text/{version}/{section}/{page}/{tab}', + path: '/api/{version}/{section}/{page}/{tab}', method: 'GET', description: 'Get raw markdown/MDX content for a specific tab', parameters: [ @@ -162,11 +162,11 @@ export const GET: APIRoute = async () => usage: { description: 'Navigate the API hierarchically to discover and retrieve documentation', exampleFlow: [ - 'GET /text/versions → ["v6"]', - 'GET /text/v6 → ["components", "layouts", ...]', - 'GET /text/v6/components → ["alert", "button", ...]', - 'GET /text/v6/components/alert → ["react", "html", ...]', - 'GET /text/v6/components/alert/react → (markdown content)', + 'GET /api/versions → ["v6"]', + 'GET /api/v6 → ["components", "layouts", ...]', + 'GET /api/v6/components → ["alert", "button", ...]', + 'GET /api/v6/components/alert → ["react", "html", ...]', + 'GET /api/v6/components/alert/react → (markdown content)', ], }, }) \ No newline at end of file diff --git a/src/pages/api/testHelpers.ts b/src/pages/api/testHelpers.ts new file mode 100644 index 0000000..1666174 --- /dev/null +++ b/src/pages/api/testHelpers.ts @@ -0,0 +1,102 @@ +/** + * Test helpers for API route tests + */ + +export const mockContentCollections = { + v6: [ + { + name: 'react-component-docs', + base: '/mock/path/react', + pattern: '**/*.md', + version: 'v6' + }, + { + name: 'core-docs', + base: '/mock/path/core', + pattern: '**/*.md', + version: 'v6' + } + ] +}; + +export const mockEntriesWithBody = { + 'react-component-docs': [ + { + id: 'components/alert/react', + slug: 'components/alert/react', + body: '# Alert Component\n\nReact Alert documentation content', + data: { + id: 'Alert', + title: 'Alert', + section: 'components', + tab: 'react' + }, + collection: 'react-component-docs' + }, + { + id: 'components/alert/html', + slug: 'components/alert/html', + body: '# Alert HTML\n\nHTML Alert documentation content', + data: { + id: 'Alert', + title: 'Alert', + section: 'components', + tab: 'html' + }, + collection: 'react-component-docs' + }, + { + id: 'components/alert/react-demos', + slug: 'components/alert/react-demos', + body: '# Alert Demos\n\nReact demos content', + data: { + id: 'Alert', + title: 'Alert Demos', + section: 'components', + tab: 'react-demos' + }, + collection: 'react-component-docs' + } + ], + 'core-docs': [] +}; + +export const createGetCollectionMock = (collections: Record) => { + return jest.fn((collectionName: string) => { + return Promise.resolve(collections[collectionName] || []); + }); +}; + +export const mockUtils = { + isReactTab: jest.fn((tab: string) => tab.includes('react')), + isHtmlTab: jest.fn((tab: string) => tab.includes('html')), + isDemosTab: jest.fn((tab: string) => tab.includes('demos')), + transformTabSlug: jest.fn((slug: string) => slug), + addDemosOrDeprecated: jest.fn((tabName: string, filePath?: string) => { + if (!filePath || !tabName) { + return '' + } + let result = tabName + if (filePath.includes('demos') && !tabName.includes('-demos')) { + result += '-demos' + } + if (filePath.includes('deprecated') && !tabName.includes('-deprecated')) { + result += '-deprecated' + } + return result + }), + kebabCase: jest.fn((id: string) => { + if (!id) return '' + return id + .replace(/PatternFly/g, 'Patternfly') + .replace(/([a-z])([A-Z])/g, '$1-$2') + .replace(/[\s_]+/g, '-') + .toLowerCase() + }), + getDefaultTab: jest.fn((filePath?: string) => { + if (!filePath) return 'react' + if (filePath.includes('react')) return 'react' + if (filePath.includes('html')) return 'html' + return 'react' + }) +}; diff --git a/test.setup.ts b/test.setup.ts index 52b79e9..d9b3120 100644 --- a/test.setup.ts +++ b/test.setup.ts @@ -1,2 +1,28 @@ // Add custom jest matchers from jest-dom import '@testing-library/jest-dom'; + +// Polyfill Response for Node.js test environment +if (typeof global.Response === 'undefined') { + global.Response = class Response { + constructor(public body: any, public init?: ResponseInit) {} + + status = this.init?.status || 200; + statusText = this.init?.statusText || ''; + headers = { + entries: () => Object.entries(this.init?.headers || {}), + get: (key: string) => { + const headers = this.init?.headers || {}; + const value = (headers as any)[key]; + return value !== undefined ? value : null; + }, + }; + + async text() { + return typeof this.body === 'string' ? this.body : JSON.stringify(this.body); + } + + async json() { + return typeof this.body === 'string' ? JSON.parse(this.body) : this.body; + } + } as any; +} From e5c9b90fd9dbe716680e798fbbe5c9599d0b2082 Mon Sep 17 00:00:00 2001 From: Austin Sullivan Date: Thu, 4 Dec 2025 14:02:09 -0500 Subject: [PATCH 4/6] move tests outside of pages dir so that astro doesn't try to render them --- jest.config.ts | 1 + .../helpers/apiTestHelpers.ts} | 0 .../pages/api/__tests__/[version].test.ts | 4 +- .../[version]/[section]/[page]/[tab].test.ts | 83 ++++++++++++++----- .../pages/api/__tests__/versions.test.ts | 4 +- 5 files changed, 67 insertions(+), 25 deletions(-) rename src/{pages/api/testHelpers.ts => __tests__/helpers/apiTestHelpers.ts} (100%) rename src/{ => __tests__}/pages/api/__tests__/[version].test.ts (95%) rename src/{ => __tests__}/pages/api/__tests__/[version]/[section]/[page]/[tab].test.ts (61%) rename src/{ => __tests__}/pages/api/__tests__/versions.test.ts (89%) diff --git a/jest.config.ts b/jest.config.ts index c98515e..d9d5be4 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -13,6 +13,7 @@ const config: Config = { '^.+\\.m?jsx?$': 'babel-jest', }, testRegex: '(/__tests__/.*|(\\.|/)(test|spec))\\.[jt]sx?$', + testPathIgnorePatterns: ['/node_modules/', '/__tests__/helpers/'], moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], moduleNameMapper: { '\\.(css|less)$': '/src/__mocks__/styleMock.ts', diff --git a/src/pages/api/testHelpers.ts b/src/__tests__/helpers/apiTestHelpers.ts similarity index 100% rename from src/pages/api/testHelpers.ts rename to src/__tests__/helpers/apiTestHelpers.ts diff --git a/src/pages/api/__tests__/[version].test.ts b/src/__tests__/pages/api/__tests__/[version].test.ts similarity index 95% rename from src/pages/api/__tests__/[version].test.ts rename to src/__tests__/pages/api/__tests__/[version].test.ts index cb2a7f4..531f69c 100644 --- a/src/pages/api/__tests__/[version].test.ts +++ b/src/__tests__/pages/api/__tests__/[version].test.ts @@ -1,10 +1,10 @@ -import { GET } from '../[version]' +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', () => ({ +jest.mock('../../../../apiIndex.json', () => ({ versions: ['v5', 'v6'], sections: { v5: ['getting-started'], diff --git a/src/pages/api/__tests__/[version]/[section]/[page]/[tab].test.ts b/src/__tests__/pages/api/__tests__/[version]/[section]/[page]/[tab].test.ts similarity index 61% rename from src/pages/api/__tests__/[version]/[section]/[page]/[tab].test.ts rename to src/__tests__/pages/api/__tests__/[version]/[section]/[page]/[tab].test.ts index c3e9867..93ebf83 100644 --- a/src/pages/api/__tests__/[version]/[section]/[page]/[tab].test.ts +++ b/src/__tests__/pages/api/__tests__/[version]/[section]/[page]/[tab].test.ts @@ -1,42 +1,83 @@ -import { GET } from '../../../../[version]/[section]/[page]/[tab]' +import { GET } from '../../../../../../../pages/api/[version]/[section]/[page]/[tab]' /** * Mock content collections with entries that have body content * to test markdown/MDX content retrieval */ -jest.mock('../../../../../../content', () => { - const { mockContentCollections } = jest.requireActual('../../../../testHelpers.ts') - return { content: mockContentCollections.v6 } -}) +jest.mock('../../../../../../../content', () => ({ + content: [ + { name: 'react-component-docs', base: '/mock/path/react', pattern: '**/*.md', version: 'v6' }, + { name: 'core-docs', base: '/mock/path/core', pattern: '**/*.md', version: 'v6' } + ] +})) /** * Mock getCollection to return entries with body (markdown content) * simulating real documentation pages with content */ -jest.mock('astro:content', () => { - const { mockEntriesWithBody, createGetCollectionMock } = jest.requireActual( - '../../../../testHelpers.ts', - ) - return { - getCollection: createGetCollectionMock({ - 'react-component-docs': mockEntriesWithBody['react-component-docs'], - 'core-docs': mockEntriesWithBody['core-docs'], - }), - } -}) +jest.mock('astro:content', () => ({ + getCollection: jest.fn((collectionName: string) => { + const mockData: Record = { + 'react-component-docs': [ + { + id: 'components/alert/react', + slug: 'components/alert/react', + body: '# Alert Component\n\nReact Alert documentation content', + data: { id: 'Alert', title: 'Alert', section: 'components', tab: 'react' }, + collection: 'react-component-docs' + }, + { + id: 'components/alert/html', + slug: 'components/alert/html', + body: '# Alert HTML\n\nHTML Alert documentation content', + data: { id: 'Alert', title: 'Alert', section: 'components', tab: 'html' }, + collection: 'react-component-docs' + }, + { + id: 'components/alert/react-demos', + slug: 'components/alert/react-demos', + body: '# Alert Demos\n\nReact demos content', + data: { id: 'Alert', title: 'Alert Demos', section: 'components', tab: 'react-demos' }, + collection: 'react-component-docs' + } + ], + 'core-docs': [] + } + return Promise.resolve(mockData[collectionName] || []) + }) +})) /** * Mock utilities for tab identification and transformation */ -jest.mock('../../../../../../utils', () => { - const { mockUtils } = jest.requireActual('../../../../testHelpers.ts') - return mockUtils -}) +jest.mock('../../../../../../../utils', () => ({ + kebabCase: jest.fn((id: string) => { + if (!id) return '' + return id + .replace(/PatternFly/g, 'Patternfly') + .replace(/([a-z])([A-Z])/g, '$1-$2') + .replace(/[\s_]+/g, '-') + .toLowerCase() + }), + getDefaultTab: jest.fn((filePath?: string) => { + if (!filePath) return 'react' + if (filePath.includes('react')) return 'react' + if (filePath.includes('html')) return 'html' + return 'react' + }), + addDemosOrDeprecated: jest.fn((tabName: string, filePath?: string) => { + if (!filePath || !tabName) return '' + let result = tabName + if (filePath.includes('demos') && !tabName.includes('-demos')) result += '-demos' + if (filePath.includes('deprecated') && !tabName.includes('-deprecated')) result += '-deprecated' + return result + }) +})) /** * Mock API index to validate paths */ -jest.mock('../../../../../../utils/apiIndex/get', () => ({ +jest.mock('../../../../../../../utils/apiIndex/get', () => ({ getApiIndex: jest.fn().mockResolvedValue({ versions: ['v6'], sections: { diff --git a/src/pages/api/__tests__/versions.test.ts b/src/__tests__/pages/api/__tests__/versions.test.ts similarity index 89% rename from src/pages/api/__tests__/versions.test.ts rename to src/__tests__/pages/api/__tests__/versions.test.ts index 7948e7b..5a905c4 100644 --- a/src/pages/api/__tests__/versions.test.ts +++ b/src/__tests__/pages/api/__tests__/versions.test.ts @@ -1,9 +1,9 @@ -import { GET } from '../versions' +import { GET } from '../../../../pages/api/versions' /** * Mock apiIndex.json with multiple versions */ -jest.mock('../../../apiIndex.json', () => ({ +jest.mock('../../../../apiIndex.json', () => ({ versions: ['v5', 'v6'], sections: {}, pages: {}, From 54de4df9a8b92f4f032efb69e57ecacdfd176899 Mon Sep 17 00:00:00 2001 From: Austin Sullivan Date: Thu, 4 Dec 2025 14:08:27 -0500 Subject: [PATCH 5/6] lint --- src/__tests__/helpers/apiTestHelpers.ts | 57 +++++++------ .../[version]/[section]/[page]/[tab].test.ts | 79 ++++++++++++++----- .../api/[version]/[section]/[page]/[tab].ts | 41 ++++++---- src/utils/apiIndex/generate.ts | 7 +- 4 files changed, 122 insertions(+), 62 deletions(-) diff --git a/src/__tests__/helpers/apiTestHelpers.ts b/src/__tests__/helpers/apiTestHelpers.ts index 1666174..0532447 100644 --- a/src/__tests__/helpers/apiTestHelpers.ts +++ b/src/__tests__/helpers/apiTestHelpers.ts @@ -8,16 +8,16 @@ export const mockContentCollections = { name: 'react-component-docs', base: '/mock/path/react', pattern: '**/*.md', - version: 'v6' + version: 'v6', }, { name: 'core-docs', base: '/mock/path/core', pattern: '**/*.md', - version: 'v6' - } - ] -}; + version: 'v6', + }, + ], +} export const mockEntriesWithBody = { 'react-component-docs': [ @@ -29,9 +29,9 @@ export const mockEntriesWithBody = { id: 'Alert', title: 'Alert', section: 'components', - tab: 'react' + tab: 'react', }, - collection: 'react-component-docs' + collection: 'react-component-docs', }, { id: 'components/alert/html', @@ -41,9 +41,9 @@ export const mockEntriesWithBody = { id: 'Alert', title: 'Alert', section: 'components', - tab: 'html' + tab: 'html', }, - collection: 'react-component-docs' + collection: 'react-component-docs', }, { id: 'components/alert/react-demos', @@ -53,19 +53,18 @@ export const mockEntriesWithBody = { id: 'Alert', title: 'Alert Demos', section: 'components', - tab: 'react-demos' + tab: 'react-demos', }, - collection: 'react-component-docs' - } + collection: 'react-component-docs', + }, ], - 'core-docs': [] -}; + 'core-docs': [], +} -export const createGetCollectionMock = (collections: Record) => { - return jest.fn((collectionName: string) => { - return Promise.resolve(collections[collectionName] || []); - }); -}; +export const createGetCollectionMock = (collections: Record) => + jest.fn((collectionName: string) => + Promise.resolve(collections[collectionName] || []), + ) export const mockUtils = { isReactTab: jest.fn((tab: string) => tab.includes('react')), @@ -86,7 +85,9 @@ export const mockUtils = { return result }), kebabCase: jest.fn((id: string) => { - if (!id) return '' + if (!id) { + return '' + } return id .replace(/PatternFly/g, 'Patternfly') .replace(/([a-z])([A-Z])/g, '$1-$2') @@ -94,9 +95,15 @@ export const mockUtils = { .toLowerCase() }), getDefaultTab: jest.fn((filePath?: string) => { - if (!filePath) return 'react' - if (filePath.includes('react')) return 'react' - if (filePath.includes('html')) return 'html' + if (!filePath) { + return 'react' + } + if (filePath.includes('react')) { + return 'react' + } + if (filePath.includes('html')) { + return 'html' + } return 'react' - }) -}; + }), +} 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 93ebf83..656c68e 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 @@ -6,9 +6,19 @@ import { GET } from '../../../../../../../pages/api/[version]/[section]/[page]/[ */ jest.mock('../../../../../../../content', () => ({ content: [ - { name: 'react-component-docs', base: '/mock/path/react', pattern: '**/*.md', version: 'v6' }, - { name: 'core-docs', base: '/mock/path/core', pattern: '**/*.md', version: 'v6' } - ] + { + name: 'react-component-docs', + base: '/mock/path/react', + pattern: '**/*.md', + version: 'v6', + }, + { + name: 'core-docs', + base: '/mock/path/core', + pattern: '**/*.md', + version: 'v6', + }, + ], })) /** @@ -23,28 +33,43 @@ jest.mock('astro:content', () => ({ id: 'components/alert/react', slug: 'components/alert/react', body: '# Alert Component\n\nReact Alert documentation content', - data: { id: 'Alert', title: 'Alert', section: 'components', tab: 'react' }, - collection: 'react-component-docs' + data: { + id: 'Alert', + title: 'Alert', + section: 'components', + tab: 'react', + }, + collection: 'react-component-docs', }, { id: 'components/alert/html', slug: 'components/alert/html', body: '# Alert HTML\n\nHTML Alert documentation content', - data: { id: 'Alert', title: 'Alert', section: 'components', tab: 'html' }, - collection: 'react-component-docs' + data: { + id: 'Alert', + title: 'Alert', + section: 'components', + tab: 'html', + }, + collection: 'react-component-docs', }, { id: 'components/alert/react-demos', slug: 'components/alert/react-demos', body: '# Alert Demos\n\nReact demos content', - data: { id: 'Alert', title: 'Alert Demos', section: 'components', tab: 'react-demos' }, - collection: 'react-component-docs' - } + data: { + id: 'Alert', + title: 'Alert Demos', + section: 'components', + tab: 'react-demos', + }, + collection: 'react-component-docs', + }, ], - 'core-docs': [] + 'core-docs': [], } return Promise.resolve(mockData[collectionName] || []) - }) + }), })) /** @@ -52,7 +77,9 @@ jest.mock('astro:content', () => ({ */ jest.mock('../../../../../../../utils', () => ({ kebabCase: jest.fn((id: string) => { - if (!id) return '' + if (!id) { + return '' + } return id .replace(/PatternFly/g, 'Patternfly') .replace(/([a-z])([A-Z])/g, '$1-$2') @@ -60,18 +87,30 @@ jest.mock('../../../../../../../utils', () => ({ .toLowerCase() }), getDefaultTab: jest.fn((filePath?: string) => { - if (!filePath) return 'react' - if (filePath.includes('react')) return 'react' - if (filePath.includes('html')) return 'html' + if (!filePath) { + return 'react' + } + if (filePath.includes('react')) { + return 'react' + } + if (filePath.includes('html')) { + return 'html' + } return 'react' }), addDemosOrDeprecated: jest.fn((tabName: string, filePath?: string) => { - if (!filePath || !tabName) return '' + if (!filePath || !tabName) { + return '' + } let result = tabName - if (filePath.includes('demos') && !tabName.includes('-demos')) result += '-demos' - if (filePath.includes('deprecated') && !tabName.includes('-deprecated')) result += '-deprecated' + if (filePath.includes('demos') && !tabName.includes('-demos')) { + result += '-demos' + } + if (filePath.includes('deprecated') && !tabName.includes('-deprecated')) { + result += '-deprecated' + } return result - }) + }), })) /** diff --git a/src/pages/api/[version]/[section]/[page]/[tab].ts b/src/pages/api/[version]/[section]/[page]/[tab].ts index ee94428..9af8e57 100644 --- a/src/pages/api/[version]/[section]/[page]/[tab].ts +++ b/src/pages/api/[version]/[section]/[page]/[tab].ts @@ -1,11 +1,20 @@ +/* eslint-disable no-console */ import type { APIRoute, GetStaticPaths } from 'astro' import type { CollectionEntry, CollectionKey } from 'astro:content' import { getCollection } from 'astro:content' import { content } from '../../../../../content' -import { kebabCase, getDefaultTab, addDemosOrDeprecated } from '../../../../../utils' +import { + kebabCase, + getDefaultTab, + addDemosOrDeprecated, +} from '../../../../../utils' import { generateAndWriteApiIndex } from '../../../../../utils/apiIndex/generate' import { getApiIndex } from '../../../../../utils/apiIndex/get' -import { createJsonResponse, createTextResponse, createIndexKey } from '../../../../../utils/apiHelpers' +import { + createJsonResponse, + createTextResponse, + createIndexKey, +} from '../../../../../utils/apiHelpers' export const prerender = true @@ -18,7 +27,9 @@ export const getStaticPaths: GetStaticPaths = async () => { // This runs once during build when getCollection() is available const index = await generateAndWriteApiIndex() - const paths: { params: { version: string; section: string; page: string; tab: string } }[] = [] + const paths: { + params: { version: string; section: string; page: string; tab: string } + }[] = [] // Build paths from index structure for (const version of index.versions) { @@ -67,7 +78,9 @@ export const GET: APIRoute = async ({ params }) => { const pageKey = createIndexKey(version, section, page) if (!index.pages[sectionKey]?.includes(page)) { return createJsonResponse( - { error: `Page '${page}' not found in section '${section}' for version '${version}'` }, + { + error: `Page '${page}' not found in section '${section}' for version '${version}'`, + }, 404, ) } @@ -91,16 +104,14 @@ export const GET: APIRoute = async ({ params }) => { collectionsToFetch.map((name) => getCollection(name)), ) - const flatEntries = collections - .flat() - .map(({ data, filePath, ...rest }) => ({ - filePath, - ...rest, - data: { - ...data, - tab: data.tab || data.source || getDefaultTab(filePath), - }, - })) + const flatEntries = collections.flat().map(({ data, filePath, ...rest }) => ({ + filePath, + ...rest, + data: { + ...data, + tab: data.tab || data.source || getDefaultTab(filePath), + }, + })) // Find the matching entry const matchingEntry = flatEntries.find((entry: ContentEntry) => { @@ -117,7 +128,7 @@ export const GET: APIRoute = async ({ params }) => { // Log warning - indicates index/content mismatch console.warn( `[API Warning] Index exists but content not found: ${version}/${section}/${page}/${tab}. ` + - 'This may indicate a mismatch between index generation and actual content.' + 'This may indicate a mismatch between index generation and actual content.', ) return createJsonResponse( { diff --git a/src/utils/apiIndex/generate.ts b/src/utils/apiIndex/generate.ts index 3fd6efb..6fab08a 100644 --- a/src/utils/apiIndex/generate.ts +++ b/src/utils/apiIndex/generate.ts @@ -87,7 +87,9 @@ export async function generateApiIndex(): Promise { const pageTabs: Record> = {} flatEntries.forEach((entry: any) => { - if (!entry.data.section) return + if (!entry.data.section) { + return + } const section = entry.data.section const page = kebabCase(entry.data.id) @@ -104,7 +106,8 @@ export async function generateApiIndex(): Promise { sectionPages[sectionKey].add(page) // Collect tab - const entryTab = entry.data.tab || entry.data.source || getDefaultTab(entry.filePath) + const entryTab = + entry.data.tab || entry.data.source || getDefaultTab(entry.filePath) const tab = addDemosOrDeprecated(entryTab, entry.id) if (!pageTabs[pageKey]) { pageTabs[pageKey] = new Set() From 6721e2c040a098e62411ecda33aade6ab267ad01 Mon Sep 17 00:00:00 2001 From: Austin Sullivan Date: Thu, 4 Dec 2025 14:45:10 -0500 Subject: [PATCH 6/6] limit memory usage for build --- package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 3011e88..9fee6b4 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "start": "npm run dev", "start:cli": "npm run build:cli && node ./dist/cli/cli.js start", "start:astro": "astro dev", - "build": "npm run build:cli && node ./dist/cli/cli.js build", + "build": "npm run build:cli && node --max-old-space-size=4096 ./dist/cli/cli.js build", "build:astro": "astro check && astro build", "build:cli": "tsc --build ./cli/tsconfig.json", "build:cli:watch": "tsc --build --watch ./cli/tsconfig.json", @@ -19,6 +19,7 @@ "preview": "wrangler pages dev", "astro": "astro", "deploy": "wrangler pages deploy", + "versions:upload": "wrangler versions upload", "prettier": "prettier --write ./src", "lint": "eslint . --cache --cache-strategy content", "test": "jest",