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 acd9784..de85fde 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 @@ -131,11 +131,30 @@ jest.mock('../../../../../../../utils/apiIndex/get', () => ({ }), })) +/** + * Mock fetchApiIndex to return the same data as getApiIndex + */ +jest.mock('../../../../../../../utils/apiIndex/fetch', () => ({ + fetchApiIndex: 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 () => { +it('redirects to /text endpoint', async () => { + const mockRedirect = jest.fn((path: string) => new Response(null, { status: 302, headers: { Location: path } })) const response = await GET({ params: { version: 'v6', @@ -143,16 +162,17 @@ it('returns markdown/MDX content as plain text', async () => { page: 'alert', tab: 'react', }, + url: new URL('http://localhost/api/v6/components/alert/react'), + redirect: mockRedirect, } 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') + expect(mockRedirect).toHaveBeenCalledWith('/api/v6/components/alert/react/text') + expect(response.status).toBe(302) }) -it('returns different content for different tabs', async () => { +it('redirects to /text endpoint for different tabs', async () => { + const mockRedirect = jest.fn((path: string) => new Response(null, { status: 302, headers: { Location: path } })) + const reactResponse = await GET({ params: { version: 'v6', @@ -160,9 +180,14 @@ it('returns different content for different tabs', async () => { page: 'alert', tab: 'react', }, + url: new URL('http://localhost/api/v6/components/alert/react'), + redirect: mockRedirect, } as any) - const reactBody = await reactResponse.text() + expect(reactResponse.status).toBe(302) + expect(mockRedirect).toHaveBeenCalledWith('/api/v6/components/alert/react/text') + + mockRedirect.mockClear() const htmlResponse = await GET({ params: { version: 'v6', @@ -170,15 +195,16 @@ it('returns different content for different tabs', async () => { page: 'alert', tab: 'html', }, + url: new URL('http://localhost/api/v6/components/alert/html'), + redirect: mockRedirect, } as any) - const htmlBody = await htmlResponse.text() - expect(reactBody).toContain('React Alert') - expect(htmlBody).toContain('HTML') - expect(reactBody).not.toEqual(htmlBody) + expect(htmlResponse.status).toBe(302) + expect(mockRedirect).toHaveBeenCalledWith('/api/v6/components/alert/html/text') }) -it('returns demo content for demos tabs', async () => { +it('redirects demos tabs to /text endpoint', async () => { + const mockRedirect = jest.fn((path: string) => new Response(null, { status: 302, headers: { Location: path } })) const response = await GET({ params: { version: 'v6', @@ -186,14 +212,16 @@ it('returns demo content for demos tabs', async () => { page: 'alert', tab: 'react-demos', }, + url: new URL('http://localhost/api/v6/components/alert/react-demos'), + redirect: mockRedirect, } as any) - const body = await response.text() - expect(response.status).toBe(200) - expect(body).toContain('demos') + expect(response.status).toBe(302) + expect(mockRedirect).toHaveBeenCalledWith('/api/v6/components/alert/react-demos/text') }) it('returns 404 error for nonexistent version', async () => { + const mockRedirect = jest.fn() const response = await GET({ params: { version: 'v99', @@ -201,6 +229,8 @@ it('returns 404 error for nonexistent version', async () => { page: 'alert', tab: 'react', }, + url: new URL('http://localhost/api/v99/components/alert/react'), + redirect: mockRedirect, } as any) const body = await response.json() @@ -210,6 +240,7 @@ it('returns 404 error for nonexistent version', async () => { }) it('returns 404 error for nonexistent section', async () => { + const mockRedirect = jest.fn() const response = await GET({ params: { version: 'v6', @@ -217,6 +248,8 @@ it('returns 404 error for nonexistent section', async () => { page: 'alert', tab: 'react', }, + url: new URL('http://localhost/api/v6/invalid/alert/react'), + redirect: mockRedirect, } as any) const body = await response.json() @@ -225,6 +258,7 @@ it('returns 404 error for nonexistent section', async () => { }) it('returns 404 error for nonexistent page', async () => { + const mockRedirect = jest.fn() const response = await GET({ params: { version: 'v6', @@ -232,6 +266,8 @@ it('returns 404 error for nonexistent page', async () => { page: 'nonexistent', tab: 'react', }, + url: new URL('http://localhost/api/v6/components/nonexistent/react'), + redirect: mockRedirect, } as any) const body = await response.json() @@ -241,6 +277,7 @@ it('returns 404 error for nonexistent page', async () => { }) it('returns 404 error for nonexistent tab', async () => { + const mockRedirect = jest.fn() const response = await GET({ params: { version: 'v6', @@ -248,6 +285,8 @@ it('returns 404 error for nonexistent tab', async () => { page: 'alert', tab: 'nonexistent', }, + url: new URL('http://localhost/api/v6/components/alert/nonexistent'), + redirect: mockRedirect, } as any) const body = await response.json() @@ -257,12 +296,15 @@ it('returns 404 error for nonexistent tab', async () => { }) it('returns 400 error when required parameters are missing', async () => { + const mockRedirect = jest.fn() const response = await GET({ params: { version: 'v6', section: 'components', page: 'alert', }, + url: new URL('http://localhost/api/v6/components/alert'), + redirect: mockRedirect, } as any) const body = await response.json() diff --git a/src/pages/[section]/[page]/[tab].astro b/src/pages/[section]/[page]/[tab].astro index 667ef1f..7babde9 100644 --- a/src/pages/[section]/[page]/[tab].astro +++ b/src/pages/[section]/[page]/[tab].astro @@ -50,7 +50,7 @@ export async function getStaticPaths() { .filter((entry) => entry.data.tab) // only pages with a tab should match this route .map((entry) => { // Build tabs dictionary - const tab = addDemosOrDeprecated(entry.data.tab, entry.id) + const tab = addDemosOrDeprecated(entry.data.tab, entry.filePath) buildTab(entry, tab) if (entry.data.tabName) { tabNames[entry.data.tab] = entry.data.tabName diff --git a/src/pages/api/[version]/[section]/[page]/[tab].ts b/src/pages/api/[version]/[section]/[page]/[tab].ts index d770764..0a92e5f 100644 --- a/src/pages/api/[version]/[section]/[page]/[tab].ts +++ b/src/pages/api/[version]/[section]/[page]/[tab].ts @@ -1,61 +1,10 @@ -/* 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, - addDemosOrDeprecated, - getDefaultTabForApi, -} from '../../../../../utils' -import { generateAndWriteApiIndex } from '../../../../../utils/apiIndex/generate' -import { getApiIndex } from '../../../../../utils/apiIndex/get' -import { - createJsonResponse, - createTextResponse, - createIndexKey, -} from '../../../../../utils/apiHelpers' +import type { APIRoute } from 'astro' +import { fetchApiIndex } from '../../../../../utils/apiIndex/fetch' +import { createJsonResponse, createIndexKey } from '../../../../../utils/apiHelpers' -export const prerender = true +export const prerender = false -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 } }) - } - } - } - } - - // 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 }) => { +export const GET: APIRoute = async ({ params, redirect, url }) => { const { version, section, page, tab } = params if (!version || !section || !page || !tab) { @@ -66,7 +15,7 @@ export const GET: APIRoute = async ({ params }) => { } // Validate using index first (fast path for 404s) - const index = await getApiIndex() + const index = await fetchApiIndex(url) // Check if version exists if (!index.versions.includes(version)) { @@ -103,51 +52,6 @@ export const GET: APIRoute = async ({ params }) => { ) } - // 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 || getDefaultTabForApi(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) + // Redirect to the text endpoint + return redirect(`${url.pathname}/text`) } diff --git a/src/pages/api/[version]/[section]/[page]/[tab]/examples.ts b/src/pages/api/[version]/[section]/[page]/[tab]/examples.ts new file mode 100644 index 0000000..5d8db83 --- /dev/null +++ b/src/pages/api/[version]/[section]/[page]/[tab]/examples.ts @@ -0,0 +1,34 @@ +import type { APIRoute } from 'astro' +import { createJsonResponse, createIndexKey } from '../../../../../../utils/apiHelpers' +import { fetchApiIndex } from '../../../../../../utils/apiIndex/fetch' + +export const prerender = false + +export const GET: APIRoute = async ({ params, url }) => { + const { version, section, page, tab } = params + + if (!version || !section || !page || !tab) { + return createJsonResponse( + { error: 'Version, section, page, and tab parameters are required' }, + 400, + ) + } + + // Get examples with titles directly from the index + try { + const index = await fetchApiIndex(url) + const tabKey = createIndexKey(version, section, page, tab) + const examples = index.examples[tabKey] || [] + + return createJsonResponse(examples) + } catch (error) { + const details = error instanceof Error ? error.message : String(error) + return createJsonResponse( + { error: 'Failed to load API index', details }, + 500, + ) + } +} + + + diff --git a/src/pages/api/[version]/[section]/[page]/[tab]/examples/[example].ts b/src/pages/api/[version]/[section]/[page]/[tab]/examples/[example].ts new file mode 100644 index 0000000..4c13c09 --- /dev/null +++ b/src/pages/api/[version]/[section]/[page]/[tab]/examples/[example].ts @@ -0,0 +1,253 @@ +/* eslint-disable no-console */ +import type { APIRoute, GetStaticPaths } from 'astro' +import type { CollectionEntry, CollectionKey } from 'astro:content' +import { getCollection } from 'astro:content' +import { readFile } from 'fs/promises' +import { resolve } from 'path' +import { content } from '../../../../../../../content' +import { kebabCase, addDemosOrDeprecated } from '../../../../../../../utils' +import { getDefaultTabForApi } from '../../../../../../../utils/packageUtils' +import { createJsonResponse, createTextResponse, createIndexKey } from '../../../../../../../utils/apiHelpers' +import { generateAndWriteApiIndex } from '../../../../../../../utils/apiIndex/generate' + +export const prerender = true + +export const getStaticPaths: GetStaticPaths = async () => { + // Generate index file (will be cached if already generated) + const index = await generateAndWriteApiIndex() + + const paths: { + params: { + version: string + section: string + page: string + tab: string + example: 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] || []) { + const tabKey = createIndexKey(version, section, page, tab) + + // Get all examples for this tab + const examples = index.examples[tabKey] || [] + for (const example of examples) { + paths.push({ + params: { + version, + section, + page, + tab, + example: example.exampleName, + }, + }) + } + } + } + } + } + + return paths +} + +/** + * Extracts import statements from file content + * Matches import statements with relative paths (starting with ./ or ../) + * + * @param fileContent - The file content to parse + * @returns Array of import statements or null if none found + */ +function getImports(fileContent: string): string[] | null { + // Match import statements with relative paths + // Supports: import X from './path', import X from "../path/file.tsx" + const importRegex = /import\s+.*\s+from\s+['"]\.{1,2}\/[^'"]+['"]/gm + const matches = fileContent.match(importRegex) + return matches +} + +/** + * Extracts the file path for a specific example from import statements + * Looks for imports that reference the example name + * + * @param imports - Array of import statements + * @param exampleName - Name of the example to find + * @returns Relative file path without quotes (including query params like ?raw), or null if not found + */ +function getExampleFilePath(imports: string[], exampleName: string): string | null { + const exampleImport = imports.find((imp) => imp.includes(exampleName)) + if (!exampleImport) { + console.error('No import path found for example', exampleName) + return null + } + // Extract path from import statement, handling query parameters like ?raw + // Matches: "./path" or "../path" with optional file extensions and query params + const match = exampleImport.match(/['"](\.[^'"]+)['"]/i) + if (!match || !match[1]) { + return null + } + return match[1] +} + +/** + * Fetches all content collections for a specific version + * Enriches entries with default tab information if not specified + * + * @param version - The documentation version (e.g., 'v6') + * @returns Promise resolving to array of collection entries with metadata + */ +async function getCollections(version: string) { + const collectionsToFetch = content + .filter((entry) => entry.version === version) + .map((entry) => entry.name as CollectionKey) + const collections = await Promise.all( + collectionsToFetch.map(async (name) => await getCollection(name)), + ) + return collections.flat().map(({ data, filePath, ...rest }) => ({ + filePath, + ...rest, + data: { + ...data, + tab: data.tab || data.source || getDefaultTabForApi(filePath), + }, + })) +} + +/** + * Finds the file path for a content entry matching the given parameters + * Prefers .mdx files over .md files when both exist, since .mdx files + * contain the LiveExample components and example imports + * + * @param collections - Array of collection entries to search + * @param section - The section name (e.g., 'components') + * @param page - The page slug (e.g., 'alert') + * @param tab - The tab name (e.g., 'react') + * @returns Promise resolving to the file path, or null if not found + */ +async function getContentEntryFilePath( + collections: CollectionEntry<'core-docs' | 'quickstarts-docs' | 'react-component-docs'>[], + section: string, + page: string, + tab: string +): Promise { + // Find all matching entries + const matchingEntries = collections.filter((entry) => { + const entryTab = addDemosOrDeprecated(entry.data.tab, entry.filePath) + return ( + entry.data.section === section && + kebabCase(entry.data.id) === page && + entryTab === tab + ) + }) + + if (matchingEntries.length === 0) { + console.error('No content entry found for section', section, 'page', page, 'tab', tab) + return null + } + + // Prefer .mdx files over .md files (mdx files have LiveExample components) + const mdxEntry = matchingEntries.find((entry) => + typeof entry.filePath === 'string' && entry.filePath.endsWith('.mdx') + ) + const contentEntry = mdxEntry || matchingEntries[0] + + if (typeof contentEntry.filePath !== 'string') { + console.error('No file path found for content entry', contentEntry.id) + return null + } + + return contentEntry.filePath +} + +/** + * GET handler for retrieving example source code + * Returns the raw source code for a specific example + * + * @param params - Route parameters: version, section, page, tab, example + * @returns Response with example code as text/plain or error JSON + */ +export const GET: APIRoute = async ({ params }) => { + const { version, section, page, tab, example } = params + if (!version || !section || !page || !tab || !example) { + return createJsonResponse( + { error: 'Version, section, page, tab, and example parameters are required' }, + 400 + ) + } + + try { + const collections = await getCollections(version) + const contentEntryFilePath = await getContentEntryFilePath(collections, section, page, tab) + + if (!contentEntryFilePath) { + return createJsonResponse( + { error: `Content entry not found for ${version}/${section}/${page}/${tab}` }, + 404 + ) + } + + // Read content entry file to extract imports + let contentEntryFileContent: string + try { + contentEntryFileContent = await readFile(contentEntryFilePath, 'utf8') + } catch (error) { + const details = error instanceof Error ? error.message : String(error) + return createJsonResponse( + { error: 'Failed to read content entry file', details }, + 500 + ) + } + + const contentEntryImports = getImports(contentEntryFileContent) + if (!contentEntryImports) { + return createJsonResponse( + { error: 'No imports found in content entry' }, + 404 + ) + } + + const relativeExampleFilePath = getExampleFilePath(contentEntryImports, example) + if (!relativeExampleFilePath) { + return createJsonResponse( + { error: `Example "${example}" not found in imports` }, + 404 + ) + } + + // Strip query parameters (like ?raw) from the file path before reading + const cleanFilePath = relativeExampleFilePath.split('?')[0] + + // Read example file + const absoluteExampleFilePath = resolve(contentEntryFilePath, '../', cleanFilePath) + let exampleFileContent: string + try { + exampleFileContent = await readFile(absoluteExampleFilePath, 'utf8') + } catch (error) { + const details = error instanceof Error ? error.message : String(error) + // Check if it's a file not found error + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return createJsonResponse( + { error: `Example file not found at path: ${relativeExampleFilePath}` }, + 404 + ) + } + return createJsonResponse( + { error: 'Failed to read example file', details }, + 500 + ) + } + + return createTextResponse(exampleFileContent) + } catch (error) { + const details = error instanceof Error ? error.message : String(error) + return createJsonResponse( + { error: 'Internal server error', details }, + 500 + ) + } +} \ No newline at end of file diff --git a/src/pages/api/[version]/[section]/[page]/[tab]/text.ts b/src/pages/api/[version]/[section]/[page]/[tab]/text.ts new file mode 100644 index 0000000..24736f9 --- /dev/null +++ b/src/pages/api/[version]/[section]/[page]/[tab]/text.ts @@ -0,0 +1,153 @@ +/* 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, + addDemosOrDeprecated, + getDefaultTabForApi, +} 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 } }) + } + } + } + } + + // 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 }) => { + 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 || getDefaultTabForApi(filePath), + }, + })) + + // Find the matching entry + const matchingEntry = flatEntries.find((entry: ContentEntry) => { + const entryTab = addDemosOrDeprecated(entry.data.tab, entry.filePath) + 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/index.ts b/src/pages/api/index.ts index 2f1e445..cead58f 100644 --- a/src/pages/api/index.ts +++ b/src/pages/api/index.ts @@ -1,3 +1,17 @@ +/** + * PatternFly Documentation API + * + * Architecture: + * - Build-time: Static API index generated from content collections (data.ts) + * - Runtime: Routes fetch /apiIndex.json (prerendered static file) via HTTP + * - Workers-compatible: No Node.js filesystem APIs in SSR handlers + * - Optimized bundle: Runtime handlers don't bundle the 500KB+ index data + * + * Data Flow: + * 1. Build: generateApiIndex() → data.ts + apiIndex.json + * 2. Static paths: getStaticPaths() uses getApiIndex() (build-time import) + * 3. SSR handlers: GET handlers use fetchApiIndex(url) (runtime fetch) + */ import type { APIRoute } from 'astro' import { createJsonResponse } from '../../utils/apiHelpers' @@ -121,6 +135,45 @@ export const GET: APIRoute = async () => { path: '/api/{version}/{section}/{page}/{tab}', method: 'GET', + description: 'Redirects to /text endpoint after validation', + 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: 'redirect', + description: 'Redirects to /{version}/{section}/{page}/{tab}/text', + }, + }, + { + path: '/api/{version}/{section}/{page}/{tab}/text', + method: 'GET', description: 'Get raw markdown/MDX content for a specific tab', parameters: [ { @@ -158,6 +211,97 @@ export const GET: APIRoute = async () => description: 'Raw markdown/MDX documentation content', }, }, + { + path: '/api/{version}/{section}/{page}/{tab}/examples', + method: 'GET', + description: 'Get list of available examples for a 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: 'array', + items: 'object', + description: 'Array of example objects with exampleName and title', + example: [ + { exampleName: 'AlertBasic', title: 'Basic' }, + { exampleName: 'AlertVariations', title: 'Variations' }, + ], + }, + }, + { + path: '/api/{version}/{section}/{page}/{tab}/examples/{example}', + method: 'GET', + description: 'Get raw code for a specific example', + 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', + }, + { + name: 'example', + in: 'path', + required: true, + type: 'string', + example: 'AlertBasic', + }, + ], + returns: { + type: 'string', + contentType: 'text/plain; charset=utf-8', + description: 'Raw example code', + }, + }, ], usage: { description: 'Navigate the API hierarchically to discover and retrieve documentation', @@ -166,7 +310,15 @@ export const GET: APIRoute = async () => '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)', + 'GET /api/v6/components/alert/react → 302 redirect to /text', + 'GET /api/v6/components/alert/react/text → (markdown content)', + 'GET /api/v6/components/alert/react/examples → [{exampleName, title}, ...]', + 'GET /api/v6/components/alert/react/examples/AlertBasic → (example code)', ], + architecture: { + buildTime: 'Static index generated to data.ts and apiIndex.json', + runtime: 'SSR routes fetch /apiIndex.json to avoid bundling data into Workers', + optimization: 'Workers bundle is ~110K instead of 500KB+ with embedded data', + }, }, }) \ No newline at end of file diff --git a/src/pages/api/openapi.json.ts b/src/pages/api/openapi.json.ts index 8dd3c07..0262f0d 100644 --- a/src/pages/api/openapi.json.ts +++ b/src/pages/api/openapi.json.ts @@ -1,3 +1,13 @@ +/** + * OpenAPI 3.0 Specification Endpoint + * + * Architecture Note: + * This endpoint uses fetchApiIndex(url) instead of getApiIndex() to: + * - Avoid bundling 500KB+ API index data into Cloudflare Workers + * - Fetch /apiIndex.json (prerendered static file) at runtime + * - Keep Workers bundle small (~110K vs 500KB+) + * - Add only ~5-10ms latency from CDN fetch + */ import type { APIRoute } from 'astro' import { fetchApiIndex } from '../../utils/apiIndex/fetch' import { createJsonResponse } from '../../utils/apiHelpers' @@ -297,6 +307,77 @@ export const GET: APIRoute = async ({ url }) => { }, }, '/{version}/{section}/{page}/{tab}': { + get: { + summary: 'Validate and redirect to text endpoint', + description: + 'Validates the path parameters and redirects to the /text endpoint', + operationId: 'validateTab', + 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: { + '302': { + description: 'Redirects to /{version}/{section}/{page}/{tab}/text', + }, + '404': { + description: 'Tab not found', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + error: { + type: 'string', + }, + }, + }, + }, + }, + }, + }, + }, + }, + '/{version}/{section}/{page}/{tab}/text': { get: { summary: 'Get tab content', description: @@ -376,6 +457,190 @@ export const GET: APIRoute = async ({ url }) => { }, }, }, + '/{version}/{section}/{page}/{tab}/examples': { + get: { + summary: 'List available examples', + description: + 'Returns an array of available examples with their names and titles', + operationId: 'getExamples', + 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: 'List of examples', + content: { + 'application/json': { + schema: { + type: 'array', + items: { + type: 'object', + properties: { + exampleName: { + type: 'string', + }, + title: { + type: 'string', + nullable: true, + }, + }, + }, + }, + example: [ + { exampleName: 'AlertBasic', title: 'Basic' }, + { exampleName: 'AlertVariations', title: 'Variations' }, + ], + }, + }, + }, + '400': { + description: 'Missing required parameters', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + error: { + type: 'string', + }, + }, + }, + }, + }, + }, + }, + }, + }, + '/{version}/{section}/{page}/{tab}/examples/{example}': { + get: { + summary: 'Get example code', + description: + 'Returns the raw source code for a specific example', + operationId: 'getExampleCode', + 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', + }, + { + name: 'example', + in: 'path', + required: true, + description: 'Example name', + schema: { + type: 'string', + }, + example: 'AlertBasic', + }, + ], + responses: { + '200': { + description: 'Raw example code', + content: { + 'text/plain; charset=utf-8': { + schema: { + type: 'string', + }, + example: + 'import React from \'react\';\nimport { Alert } from \'@patternfly/react-core\';\n\nexport const AlertBasic = () => ;', + }, + }, + }, + '404': { + description: 'Example not found', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + error: { + type: 'string', + }, + }, + }, + }, + }, + }, + }, + }, + }, }, tags: [ { diff --git a/src/utils/__tests__/apiIndex.test.ts b/src/utils/__tests__/apiIndex.test.ts new file mode 100644 index 0000000..df044fe --- /dev/null +++ b/src/utils/__tests__/apiIndex.test.ts @@ -0,0 +1,268 @@ +/** + * Tests for API Index utilities + * + * Architecture: + * - Build-time: getApiIndex() imports static data from data.ts + * - Runtime: fetchApiIndex() fetches /apiIndex.json via HTTP + */ +import { getApiIndex, getVersions, getSections, getPages, getTabs, getExamples } from '../apiIndex/get' +import { createIndexKey } from '../apiHelpers' + +describe('getApiIndex (build-time)', () => { + it('returns API index with all required keys', async () => { + const index = await getApiIndex() + expect(index).toHaveProperty('versions') + expect(index).toHaveProperty('sections') + expect(index).toHaveProperty('pages') + expect(index).toHaveProperty('tabs') + expect(index).toHaveProperty('examples') + }) + + it('versions is a non-empty array', async () => { + const index = await getApiIndex() + expect(Array.isArray(index.versions)).toBe(true) + expect(index.versions.length).toBeGreaterThan(0) + }) + + it('sections is an object with version keys', async () => { + const index = await getApiIndex() + expect(typeof index.sections).toBe('object') + expect(Object.keys(index.sections).length).toBeGreaterThan(0) + }) + + it('pages is an object with composite keys', async () => { + const index = await getApiIndex() + expect(typeof index.pages).toBe('object') + expect(Object.keys(index.pages).length).toBeGreaterThan(0) + // Keys should be in format "version::section" + const firstKey = Object.keys(index.pages)[0] + expect(firstKey).toContain('::') + }) + + it('tabs is an object with composite keys', async () => { + const index = await getApiIndex() + expect(typeof index.tabs).toBe('object') + expect(Object.keys(index.tabs).length).toBeGreaterThan(0) + // Keys should be in format "version::section::page" + const firstKey = Object.keys(index.tabs)[0] + expect(firstKey.split('::').length).toBeGreaterThanOrEqual(3) + }) + + it('examples is an object with composite keys and example arrays', async () => { + const index = await getApiIndex() + expect(typeof index.examples).toBe('object') + const exampleKeys = Object.keys(index.examples) + expect(exampleKeys.length).toBeGreaterThan(0) + // Keys should be in format "version::section::page::tab" + const firstKey = exampleKeys[0] + expect(firstKey.split('::').length).toBe(4) + // Values should be arrays of example objects + const examples = index.examples[firstKey] + expect(Array.isArray(examples)).toBe(true) + if (examples.length > 0) { + expect(examples[0]).toHaveProperty('exampleName') + expect(examples[0]).toHaveProperty('title') + } + }) +}) + +describe('getVersions', () => { + it('returns array of version strings', async () => { + const versions = await getVersions() + expect(Array.isArray(versions)).toBe(true) + expect(versions.length).toBeGreaterThan(0) + expect(typeof versions[0]).toBe('string') + }) + + it('includes v6 version', async () => { + const versions = await getVersions() + expect(versions).toContain('v6') + }) +}) + +describe('getSections', () => { + it('returns array of sections for valid version', async () => { + const sections = await getSections('v6') + expect(Array.isArray(sections)).toBe(true) + expect(sections.length).toBeGreaterThan(0) + }) + + it('includes expected sections', async () => { + const sections = await getSections('v6') + expect(sections).toContain('components') + }) + + it('returns empty array for invalid version', async () => { + const sections = await getSections('invalid') + expect(sections).toEqual([]) + }) +}) + +describe('getPages', () => { + it('returns array of pages for valid version and section', async () => { + const pages = await getPages('v6', 'components') + expect(Array.isArray(pages)).toBe(true) + expect(pages.length).toBeGreaterThan(0) + }) + + it('includes expected pages', async () => { + const pages = await getPages('v6', 'components') + expect(pages).toContain('alert') + expect(pages).toContain('button') + }) + + it('returns empty array for invalid section', async () => { + const pages = await getPages('v6', 'invalid') + expect(pages).toEqual([]) + }) + + it('uses correct index key format', async () => { + const key = createIndexKey('v6', 'components') + const index = await getApiIndex() + expect(index.pages[key]).toBeDefined() + }) +}) + +describe('getTabs', () => { + it('returns array of tabs for valid path', async () => { + const tabs = await getTabs('v6', 'components', 'alert') + expect(Array.isArray(tabs)).toBe(true) + expect(tabs.length).toBeGreaterThan(0) + }) + + it('includes expected tabs', async () => { + const tabs = await getTabs('v6', 'components', 'alert') + expect(tabs).toContain('react') + }) + + it('returns empty array for invalid page', async () => { + const tabs = await getTabs('v6', 'components', 'invalid') + expect(tabs).toEqual([]) + }) + + it('uses correct index key format', async () => { + const key = createIndexKey('v6', 'components', 'alert') + const index = await getApiIndex() + expect(index.tabs[key]).toBeDefined() + }) +}) + +describe('getExamples', () => { + it('returns array of examples for valid path with examples', async () => { + const examples = await getExamples('v6', 'components', 'alert', 'react') + expect(Array.isArray(examples)).toBe(true) + }) + + it('returns examples with correct structure', async () => { + const examples = await getExamples('v6', 'components', 'alert', 'react') + if (examples.length > 0) { + const firstExample = examples[0] + expect(firstExample).toHaveProperty('exampleName') + expect(firstExample).toHaveProperty('title') + expect(typeof firstExample.exampleName).toBe('string') + expect(firstExample.exampleName.length).toBeGreaterThan(0) + // Title can be null or string + expect( + firstExample.title === null || typeof firstExample.title === 'string' + ).toBe(true) + } + }) + + it('returns empty array for tab without examples', async () => { + const examples = await getExamples('v6', 'components', 'nonexistent', 'react') + expect(examples).toEqual([]) + }) + + it('returns empty array for invalid parameters', async () => { + const examples = await getExamples('invalid', 'invalid', 'invalid', 'invalid') + expect(examples).toEqual([]) + }) + + it('uses correct index key format', async () => { + const key = createIndexKey('v6', 'components', 'alert', 'react') + const index = await getApiIndex() + // Key may or may not exist depending on whether there are examples + if (index.examples[key]) { + expect(Array.isArray(index.examples[key])).toBe(true) + } + }) + + it('examples are in document order', async () => { + const examples = await getExamples('v6', 'components', 'alert', 'react') + if (examples.length > 1) { + // Just verify they're all unique (order is preserved from document) + const names = examples.map((e) => e.exampleName) + const uniqueNames = new Set(names) + expect(names.length).toBe(uniqueNames.size) + } + }) +}) + +describe('API Index architecture', () => { + it('data structure supports hierarchical navigation', async () => { + const index = await getApiIndex() + + // Get version + const version = index.versions[0] + expect(version).toBeTruthy() + + // Get sections for version + const sections = index.sections[version] + expect(sections).toBeDefined() + expect(sections!.length).toBeGreaterThan(0) + + // Get pages for first section + const section = sections![0] + const sectionKey = createIndexKey(version, section) + const pages = index.pages[sectionKey] + expect(pages).toBeDefined() + expect(pages!.length).toBeGreaterThan(0) + + // Get tabs for first page + const page = pages![0] + const pageKey = createIndexKey(version, section, page) + const tabs = index.tabs[pageKey] + expect(tabs).toBeDefined() + expect(tabs!.length).toBeGreaterThan(0) + + // Get examples for first tab (if available) + const tab = tabs![0] + const tabKey = createIndexKey(version, section, page, tab) + const examples = index.examples[tabKey] + // Examples may or may not exist for all tabs + if (examples) { + expect(Array.isArray(examples)).toBe(true) + } + }) + + it('uses consistent key delimiter (::)', async () => { + const index = await getApiIndex() + + // Check pages keys + const pagesKeys = Object.keys(index.pages) + expect(pagesKeys.every(key => key.includes('::'))).toBe(true) + + // Check tabs keys + const tabsKeys = Object.keys(index.tabs) + expect(tabsKeys.every(key => key.includes('::'))).toBe(true) + + // Check examples keys + const examplesKeys = Object.keys(index.examples) + expect(examplesKeys.every(key => key.includes('::'))).toBe(true) + }) +}) + +/** + * Note: fetchApiIndex() is not tested here because it requires: + * 1. A running server to serve /apiIndex.json + * 2. A URL object from the request context + * + * Integration tests for fetchApiIndex() should be in end-to-end tests + * that can spin up a dev/preview server. + * + * Architecture decision: + * - getApiIndex(): Used at build-time (getStaticPaths), imports static data + * - fetchApiIndex(): Used at runtime (GET handlers), fetches via HTTP + * - Both return the same ApiIndex structure + * - fetchApiIndex() avoids bundling 500KB+ data into Cloudflare Workers + */ diff --git a/src/utils/apiIndex/generate.ts b/src/utils/apiIndex/generate.ts index 6e47e28..4a92211 100644 --- a/src/utils/apiIndex/generate.ts +++ b/src/utils/apiIndex/generate.ts @@ -43,6 +43,47 @@ export interface ApiIndex { pages: Record /** Tabs by version::section::page (e.g., { 'v6::components::alert': ['react', 'html'] }) */ tabs: Record + /** Examples by version::section::page::tab with titles (e.g., { 'v6::components::alert::react': [{exampleName: 'AlertDefault', title: 'Default alert'}] }) */ + examples: Record +} + +/** + * Extracts examples with titles from markdown/MDX content + * Looks for patterns and finds associated H3 titles + * + * @param body - The raw markdown/MDX text content + * @returns Array of unique examples with titles in document order + */ +function extractExamplesWithTitles( + body: string +): { exampleName: string; title: string | null }[] { + if (!body) { + return [] + } + + // Match tags with src attribute containing an example name + // Supports various attribute orders and spacing patterns + const exampleRegex = /]*\ssrc=\{\s*(\w+)\s*\}[^>]*\/?>/g + const examples: { exampleName: string; title: string | null }[] = [] + const seen = new Set() + let match + + // Find all examples and their positions + while ((match = exampleRegex.exec(body)) !== null) { + const exampleName = match[1] + if (exampleName && !seen.has(exampleName)) { + seen.add(exampleName) + + // Find the most recent h3 before this example + const textBeforeExample = body.substring(0, match.index) + const h3Matches = [...textBeforeExample.matchAll(/^### (.+)$/gm)] + const title = h3Matches.length > 0 ? h3Matches[h3Matches.length - 1][1].trim() : null + + examples.push({ exampleName, title }) + } + } + + return examples } /** @@ -60,6 +101,7 @@ export async function generateApiIndex(): Promise { sections: {}, pages: {}, tabs: {}, + examples: {}, } // Get all versions @@ -87,6 +129,7 @@ export async function generateApiIndex(): Promise { const sections = new Set() const sectionPages: Record> = {} const pageTabs: Record> = {} + const tabExamples: Record = {} flatEntries.forEach((entry: any) => { if (!entry.data.section) { @@ -110,11 +153,18 @@ export async function generateApiIndex(): Promise { // Collect tab const entryTab = entry.data.tab || entry.data.source || getDefaultTabForApi(entry.filePath) - const tab = addDemosOrDeprecated(entryTab, entry.id) + const tab = addDemosOrDeprecated(entryTab, entry.filePath) if (!pageTabs[pageKey]) { pageTabs[pageKey] = new Set() } pageTabs[pageKey].add(tab) + + // Collect examples for this tab + const tabKey = `${version}::${section}::${page}::${tab}` + const examplesWithTitles = extractExamplesWithTitles(entry.body || '') + if (examplesWithTitles.length > 0) { + tabExamples[tabKey] = examplesWithTitles + } }) // Convert sets to sorted arrays @@ -127,6 +177,10 @@ export async function generateApiIndex(): Promise { Object.entries(pageTabs).forEach(([key, tabs]) => { index.tabs[key] = Array.from(tabs).sort(sortSources) }) + + Object.entries(tabExamples).forEach(([key, examples]) => { + index.examples[key] = examples + }) } return index diff --git a/src/utils/apiIndex/get.ts b/src/utils/apiIndex/get.ts index 333b273..a20c3b8 100644 --- a/src/utils/apiIndex/get.ts +++ b/src/utils/apiIndex/get.ts @@ -23,6 +23,10 @@ export async function getApiIndex(): Promise { throw new Error('Invalid API index structure: missing or invalid "versions" array') } + if (!parsed.examples || typeof parsed.examples !== 'object') { + throw new Error('Invalid API index structure: missing or invalid "examples" object') + } + return parsed as ApiIndex } catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') { @@ -92,3 +96,24 @@ export async function getTabs(version: string, section: string, page: string): P const key = createIndexKey(version, section, page) return index.tabs[key] || [] } + +/** + * Gets all examples for a specific tab + * + * @param version - The documentation version (e.g., 'v6') + * @param section - The section name (e.g., 'components') + * @param page - The page slug (e.g., 'alert') + * @param tab - The tab name (e.g., 'react') + * @returns Promise resolving to array of examples with titles, or empty array if not found + */ +export async function getExamples( + version: string, + section: string, + page: string, + tab: string, +): Promise<{ exampleName: string; title: string | null }[]> { + const index = await getApiIndex() + const { createIndexKey } = await import('../apiHelpers') + const key = createIndexKey(version, section, page, tab) + return index.examples[key] || [] +} diff --git a/src/utils/apiIndex/index.ts b/src/utils/apiIndex/index.ts index 5531440..e338f61 100644 --- a/src/utils/apiIndex/index.ts +++ b/src/utils/apiIndex/index.ts @@ -1,3 +1,20 @@ +/** + * API Index utilities for PatternFly documentation + * + * This module provides two different ways to access the API index: + * + * 1. Build-time (getApiIndex): Imports static data from data.ts + * - Used in getStaticPaths() and other build-time code + * - Has access to Node.js filesystem APIs + * - Bundles data into the module + * + * 2. Runtime (fetchApiIndex): Fetches /apiIndex.json via HTTP + * - Used in SSR route handlers (Cloudflare Workers) + * - Avoids bundling 500KB+ data into Worker + * - Adds ~5-10ms fetch latency from CDN + * - Fully compatible with Workers environment + */ + export { generateApiIndex, writeApiIndex, @@ -5,4 +22,6 @@ export { type ApiIndex, } from './generate' -export { getApiIndex, getVersions, getSections, getPages, getTabs } from './get' +export { getApiIndex, getVersions, getSections, getPages, getTabs, getExamples } from './get' + +export { fetchApiIndex } from './fetch'