diff --git a/cli/__tests__/convertToMDX.test.ts b/cli/__tests__/convertToMDX.test.ts index ddc6676..1604b56 100644 --- a/cli/__tests__/convertToMDX.test.ts +++ b/cli/__tests__/convertToMDX.test.ts @@ -41,6 +41,78 @@ it('should convert a file with JS/TS examples', async () => { expect(writeFile).toHaveBeenCalledWith('test.mdx', expectedContent) }) +it.each([ + ['ts isFullscreen file="./BackdropBasic.tsx"', 'BackdropBasic', './BackdropBasic.tsx'], + ['ts file="CardSubtitle.tsx" isBeta', 'CardSubtitle', './CardSubtitle.tsx'], + ['tsx file="../other-package/examples/DataListDraggable.tsx"', 'DataListDraggable', '../other-package/examples/DataListDraggable.tsx'], +])('converts file fences with options and relative paths: %s', async (fence, name, file) => { + ;(glob as unknown as jest.Mock).mockResolvedValue(['test.md']) + ;(readFile as jest.Mock).mockResolvedValue(`\`\`\`${fence}\n\n\`\`\``) + + await convertToMDX('test.md') + + expect(writeFile).toHaveBeenCalledWith('test.mdx', + `\nimport ${name} from "${file}?raw"\n\n`) +}) + +it('keeps inline code samples intact', async () => { + const content = '```ts file="Example.tsx"\nconst value = 1\n```' + ;(glob as unknown as jest.Mock).mockResolvedValue(['test.md']) + ;(readFile as jest.Mock).mockResolvedValue(content) + + await convertToMDX('test.md') + + expect(writeFile).toHaveBeenCalledWith('test.mdx', content) +}) + +it('converts indented file fences with matching backtick or tilde closers', async () => { + ;(glob as unknown as jest.Mock).mockResolvedValue(['test.md']) + ;(readFile as jest.Mock).mockResolvedValue( + ' ````tsx file="Indented.tsx"\n `````\n~~~ts file="Tilde.ts"\n~~~~', + ) + + await convertToMDX('test.md') + + const converted = (writeFile as jest.Mock).mock.calls[0][1] + expect(converted).toContain('import Indented from "./Indented.tsx?raw"') + expect(converted).toContain('import Tilde from "./Tilde.ts?raw"') +}) + +it('does not convert a fence containing a four-space-indented marker', async () => { + const content = '```ts file="Example.ts"\n ```\n```' + ;(glob as unknown as jest.Mock).mockResolvedValue(['test.md']) + ;(readFile as jest.Mock).mockResolvedValue(content) + + await convertToMDX('test.md') + + expect(writeFile).toHaveBeenCalledWith('test.mdx', content) +}) + +it('keeps file fences with invalid closing markers intact', async () => { + const content = '````ts file="Example.ts"\n```\n~~~ts file="Tilde.ts"\n~~~ text' + ;(glob as unknown as jest.Mock).mockResolvedValue(['test.md']) + ;(readFile as jest.Mock).mockResolvedValue(content) + + await convertToMDX('test.md') + + expect(writeFile).toHaveBeenCalledWith('test.mdx', content) +}) + +it('reuses repeated imports and disambiguates filenames in different directories', async () => { + ;(glob as unknown as jest.Mock).mockResolvedValue(['test.md']) + ;(readFile as jest.Mock).mockResolvedValue( + ['one/Example.tsx', 'one/Example.tsx', 'two/Example.tsx'] + .map((file) => `\`\`\`ts file="${file}"\n\`\`\``).join('\n'), + ) + + await convertToMDX('test.md') + + const converted = (writeFile as jest.Mock).mock.calls[0][1] + expect(converted.match(/import Example from/g)).toHaveLength(1) + expect(converted).toContain('import Example_2 from "./two/Example.tsx?raw"') + expect(converted).toContain('') +}) + it('should convert a file with HTML examples', async () => { const mockContent = '# Test Content\n```html\n
Test HTML
\n```' const expectedContent = @@ -160,4 +232,3 @@ it('should preserve HTML comments in HTML files', async () => { expectedHTMLContent, ) }) - diff --git a/cli/__tests__/tsDocGen.test.ts b/cli/__tests__/tsDocGen.test.ts new file mode 100644 index 0000000..4996ba7 --- /dev/null +++ b/cli/__tests__/tsDocGen.test.ts @@ -0,0 +1,51 @@ +/** @jest-environment node */ +import { readFile } from 'fs/promises' +import { tsDocgen } from '../tsDocGen' + +jest.mock('fs/promises', () => ({ readFile: jest.fn() })) + +it('extracts a forwarded component and its exported base without losing props', async () => { + ;(readFile as jest.Mock).mockResolvedValue(` + import React, { forwardRef } from 'react'; + export interface InputGroupProps { + /** Content in the input group */ + children: React.ReactNode; + /** @hide Internal ref */ + innerRef?: React.Ref; + } + export const InputGroupBase = ({ children, innerRef }: InputGroupProps) => +
{children}
; + InputGroupBase.displayName = 'InputGroupBase'; + export const InputGroup = forwardRef((props: InputGroupProps, ref: React.Ref) => + ); + InputGroup.displayName = 'InputGroup'; + `) + + const result = await tsDocgen('InputGroup.tsx') + for (const name of ['InputGroupBase', 'InputGroup']) { + const component = result.find((item) => item.name === name) + expect(component?.props).toEqual([ + expect.objectContaining({ name: 'children', required: true, description: 'Content in the input group' }), + ]) + } +}) + +it('extracts multiple documented components exported from one file', async () => { + ;(readFile as jest.Mock).mockResolvedValue(` + import React from 'react'; + interface FooterProps { label: string } + interface WrapperProps { children: React.ReactNode } + export const Footer = ({ label }: FooterProps) =>
{label}
; + export const Wrapper = ({ children }: WrapperProps) =>
{children}
; + Footer.displayName = 'Footer'; + Wrapper.displayName = 'Wrapper'; + `) + + const result = await tsDocgen('Footer.tsx') + expect(result.find((item) => item.name === 'Footer')?.props).toEqual([ + expect.objectContaining({ name: 'label', type: 'string', required: true }), + ]) + expect(result.find((item) => item.name === 'Wrapper')?.props).toEqual([ + expect.objectContaining({ name: 'children', required: true }), + ]) +}) diff --git a/cli/cli.ts b/cli/cli.ts index e9bccb4..9326ece 100755 --- a/cli/cli.ts +++ b/cli/cli.ts @@ -84,7 +84,7 @@ async function generateProps(program: Command, forceProps: boolean = false) { console.log('Verbose mode enabled') } - buildPropsData(rootDir, `${currentDir}/pf-docs.config.mjs`, verbose) + await buildPropsData(rootDir, `${currentDir}/pf-docs.config.mjs`, verbose) } async function transformMDContentToMDX() { diff --git a/cli/convertToMDX.ts b/cli/convertToMDX.ts index 608cc3c..5fcae3a 100644 --- a/cli/convertToMDX.ts +++ b/cli/convertToMDX.ts @@ -4,12 +4,43 @@ import path from 'path' import { fileExists } from './fileExists.js' function handleTsExamples(content: string): string { - //regex link: https://regexr.com/8f0bu - const ExampleBlockRegex = /```[tj]s file=['"]\.?\/?(\w*)\.(\w*)['"]\s*\n```/g + // File fences may include options before or after file=, and refer to sibling packages. + // Only convert empty fences; inline code samples should remain code blocks. + const exampleBlockRegex = /^ {0,3}([`~]{3,})[tj]sx?\b([^\r\n]*)\r?\n(?:[ \t]*\r?\n)* {0,3}([`~]+[ \t]*)$/gm + const imports = new Map() + const names = new Set() + + return content.replace(exampleBlockRegex, (block, openingFence: string, attributes: string, closingFence: string) => { + const closingMarker = closingFence.trim() + if (!/^`+$|^~+$/.test(openingFence) || + !closingMarker.startsWith(openingFence) || + !new RegExp(`^${openingFence[0]}+$`).test(closingMarker)) { + return block + } + + const file = attributes.match(/\bfile=(['"])([^'"\r\n]+\.[tj]sx?)\1/) + if (!file) { + return block + } + + const filePath = file[2].startsWith('.') ? file[2] : `./${file[2]}` + let name = imports.get(filePath) + let importStatement = '' + if (!name) { + const baseName = path.basename(filePath, path.extname(filePath)).replace(/\W/g, '_') + const identifier = /^[A-Za-z_]/.test(baseName) ? baseName : `Example_${baseName}` + name = identifier + let suffix = 2 + while (names.has(name)) { + name = `${identifier}_${suffix++}` + } + names.add(name) + imports.set(filePath, name) + importStatement = `\nimport ${name} from ${JSON.stringify(`${filePath}?raw`)}\n` + } - //the first capture group is the example file name without the extension or path, the second is the extension - const replacementString = `\nimport $1 from "./$1.$2?raw"\n\n` - return content.replace(ExampleBlockRegex, replacementString) + return `${importStatement}\n` + }) } async function handleHTMLExamples( diff --git a/cli/tsDocGen.js b/cli/tsDocGen.js index 69aedd5..162da4d 100644 --- a/cli/tsDocGen.js +++ b/cli/tsDocGen.js @@ -1,5 +1,5 @@ import { readFile } from 'fs/promises' -import { parse } from 'react-docgen' +import { parse, builtinResolvers } from 'react-docgen' import ts from 'typescript' const annotations = [ @@ -44,7 +44,10 @@ function addAnnotations(prop) { function getComponentMetadata(filename, sourceText) { let parsedComponents = null try { - parsedComponents = parse(sourceText, { filename }) + parsedComponents = parse(sourceText, { + filename, + resolver: new builtinResolvers.FindExportedDefinitionsResolver(), + }) // eslint-disable-next-line @typescript-eslint/no-unused-vars } catch (_err) { // console.warn(`No component found in ${filename}:`, err); diff --git a/jest.config.ts b/jest.config.ts index 8348831..9ce7da4 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -18,7 +18,7 @@ const config: Config = { moduleNameMapper: { '\\.(css|less)$': '/src/__mocks__/styleMock.ts', '^astro:content$': '/src/__mocks__/astro-content.ts', - '(.+)\\.js': '$1', + '(.+)\\.js$': '$1', }, setupFilesAfterEnv: ['/test.setup.ts'], transformIgnorePatterns: [ diff --git a/src/__tests__/pages/api/__tests__/[version]/[section]/[page]/props.test.ts b/src/__tests__/pages/api/__tests__/[version]/[section]/[page]/props.test.ts index 687dc7e..e9bb0d5 100644 --- a/src/__tests__/pages/api/__tests__/[version]/[section]/[page]/props.test.ts +++ b/src/__tests__/pages/api/__tests__/[version]/[section]/[page]/props.test.ts @@ -5,6 +5,10 @@ import { removeSubsection } from '../../../../../../../utils/case' * Mock fetchProps to return props data */ const mockFetchProps = jest.fn() +const mockFetchApiIndex = jest.fn() +jest.mock('../../../../../../../utils/apiIndex/fetch', () => ({ + fetchApiIndex: (...args: any[]) => mockFetchApiIndex(...args), +})) jest.mock('../../../../../../../utils/propsData/fetch', () => ({ fetchProps: (...args: any[]) => mockFetchProps(...args), })) @@ -90,6 +94,85 @@ const mockData = { beforeEach(() => { jest.clearAllMocks() mockFetchProps.mockResolvedValue(mockData) + mockFetchApiIndex.mockResolvedValue({ propComponents: {} }) +}) + +it.each([ + ['navigation', 'Nav'], + ['file-upload_simple-file-upload', 'FileUpload'], +])('resolves the frontmatter component for %s', async (page, name) => { + mockFetchProps.mockResolvedValue({ [name]: { name, description: '', props: [] } }) + mockFetchApiIndex.mockResolvedValue({ propComponents: { [`v6::components::${page}`]: [name] } }) + const response = await GET({ + params: { version: 'v6', section: 'components', page }, + url: new URL(`http://localhost/api/v6/components/${page}/props`), + } as any) + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ name, description: '', props: [] }) +}) + +it('prefers the frontmatter component over a colliding page-name props record', async () => { + mockFetchProps.mockResolvedValue({ + Navigation: { name: 'Navigation', description: '', props: [] }, + Nav: { name: 'Nav', description: '', props: [] }, + }) + mockFetchApiIndex.mockResolvedValue({ + propComponents: { 'v6::components::navigation': ['Nav'] }, + }) + + const response = await GET({ + params: { version: 'v6', section: 'components', page: 'navigation' }, + url: new URL('http://localhost/api/v6/components/navigation/props'), + } as any) + + expect((await response.json()).name).toBe('Nav') +}) + +it('returns props for a documented secondary component', async () => { + mockFetchProps.mockResolvedValue({ + ...mockData, + NavItem: { name: 'NavItem', description: '', props: [] }, + }) + mockFetchApiIndex.mockResolvedValue({ + propComponents: { 'v6::components::navigation': ['Nav', 'NavList', 'NavItem'] }, + }) + const response = await GET({ + params: { version: 'v6', section: 'components', page: 'navigation' }, + url: new URL('http://localhost/api/v6/components/navigation/props?component=NavItem'), + } as any) + + expect(response.status).toBe(200) + expect((await response.json()).name).toBe('NavItem') +}) + +it('rejects a component selector outside the requested page', async () => { + mockFetchApiIndex.mockResolvedValue({ + propComponents: { 'v6::components::navigation': ['Nav', 'NavList', 'NavItem'] }, + }) + const response = await GET({ + params: { version: 'v6', section: 'components', page: 'navigation' }, + url: new URL('http://localhost/api/v6/components/navigation/props?component=Button'), + } as any) + + expect(response.status).toBe(404) +}) + +it('returns deprecated props for a deprecated-only page', async () => { + mockFetchProps.mockResolvedValue({ + ...mockData, + 'Chip-deprecated': { name: 'Chip', description: '', props: [] }, + }) + mockFetchApiIndex.mockResolvedValue({ + propComponents: { 'v6::components::chip': ['Chip', 'ChipGroup'] }, + tabs: { 'v6::components::chip': ['react-deprecated'] }, + }) + const response = await GET({ + params: { version: 'v6', section: 'components', page: 'chip' }, + url: new URL('http://localhost/api/v6/components/chip/props'), + } as any) + + expect(response.status).toBe(200) + expect((await response.json()).name).toBe('Chip') }) it('returns props data for a valid page', async () => { diff --git a/src/pages/api/[version]/[section]/[page]/props.ts b/src/pages/api/[version]/[section]/[page]/props.ts index e4fac86..7c7221e 100644 --- a/src/pages/api/[version]/[section]/[page]/props.ts +++ b/src/pages/api/[version]/[section]/[page]/props.ts @@ -1,14 +1,14 @@ import type { APIRoute } from 'astro' -import { pascalCase } from 'change-case' import { createJsonResponse } from '../../../../../utils/apiHelpers' import { fetchProps } from '../../../../../utils/propsData/fetch' -import { removeSubsection } from '../../../../../utils/case' +import { fetchApiIndex } from '../../../../../utils/apiIndex/fetch' +import { getPrimaryPropComponent } from '../../../../../utils/apiIndex/props' export const prerender = false export const GET: APIRoute = async ({ params, url }) => { - const { page } = params + const { version, section, page } = params if (!page) { return createJsonResponse( @@ -19,11 +19,26 @@ export const GET: APIRoute = async ({ params, url }) => { try { const props = await fetchProps(url) - const propsData = props[pascalCase(removeSubsection(page))] + const requestedComponent = url.searchParams.get('component') + const index = await fetchApiIndex(url) + const indexKey = `${version}::${section}::${page}` + const propComponents = index.propComponents?.[indexKey] || [] + const component = requestedComponent ?? getPrimaryPropComponent(page, propComponents) + const tabs = index.tabs?.[indexKey] || [] + const isDeprecatedOnly = !tabs.includes('react') && tabs.includes('react-deprecated') + let propsData = props[`${component}${isDeprecatedOnly ? '-deprecated' : ''}`] + + // Page labels can differ from their primary React component name. A documented + // member can be selected without exposing props outside its parent page. + if (requestedComponent !== null) { + propsData = !propComponents.includes(component) + ? undefined + : props[`${component}${isDeprecatedOnly ? '-deprecated' : ''}`] + } if (propsData === undefined) { return createJsonResponse( - { error: `Props data for ${page} not found` }, + { error: `Props data for ${requestedComponent ?? page} not found` }, 404, ) } diff --git a/src/pages/api/component-index.json.ts b/src/pages/api/component-index.json.ts index 390f6f8..063638e 100644 --- a/src/pages/api/component-index.json.ts +++ b/src/pages/api/component-index.json.ts @@ -1,5 +1,6 @@ import type { APIRoute } from 'astro' -import { getApiIndex } from '../../utils/apiIndex/get' +import { generateApiIndex } from '../../utils/apiIndex/generate' +import { getPrimaryPropComponent } from '../../utils/apiIndex/props' import { removeSubsection } from '../../utils/case' import { getOutputDir } from '../../utils/getOutputDir' import { join } from 'path' @@ -14,6 +15,7 @@ interface ComponentEntry { hasProps: boolean hasCss: boolean exampleCount: number + component: string } export interface ComponentIndex { @@ -31,20 +33,17 @@ function pageToPascalCase(page: string): string { export const GET: APIRoute = async () => { try { - const index = await getApiIndex() + // This route can prerender before the routes that write apiIndex.json. + // Generate from current collections rather than a previous build's index. + const index = await generateApiIndex() - // props.json keys include both component names ("Alert") and their prop - // interfaces ("AlertProps"). Filter out the interface entries to get the - // set of component names that have prop documentation available. + // Frontmatter may document both component and interface props records. let componentNamesWithProps = new Set() try { const outputDir = await getOutputDir() const propsFile = await readFile(join(outputDir, 'props.json'), 'utf-8') const propsData = JSON.parse(propsFile) - const propsSuffixPattern = /Props/i - componentNamesWithProps = new Set( - Object.keys(propsData).filter((name) => !propsSuffixPattern.test(name)), - ) + componentNamesWithProps = new Set(Object.keys(propsData)) } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { throw error @@ -60,6 +59,7 @@ export const GET: APIRoute = async () => { const sections = index.sections[version] || [] const components: Record = {} + const aliases: Record = {} for (const section of sections) { const pagesKey = `${version}::${section}` @@ -79,24 +79,42 @@ export const GET: APIRoute = async () => { const hasCss = tabsKey in index.css && index.css[tabsKey].length > 0 const pascalName = pageToPascalCase(page) - const hasProps = componentNamesWithProps.has(pascalName) + const propComponents = index.propComponents?.[tabsKey] || [] + const primaryComponent = getPrimaryPropComponent(page, propComponents) + const isDeprecatedOnly = !tabs.includes('react') && tabs.includes('react-deprecated') + const hasPropsForComponent = (component: string) => + componentNamesWithProps.has(`${component}${isDeprecatedOnly ? '-deprecated' : ''}`) + const hasProps = hasPropsForComponent(primaryComponent) + const pageEntry = { + section, + page, + tabs, + hasProps, + hasCss, + exampleCount, + component: primaryComponent, + } // Prefer the first occurrence when multiple sections produce the same // PascalCase key (e.g., components/table vs extensions/data-view_table) if (!components[pascalName]) { - components[pascalName] = { - section, - page, - tabs, - hasProps, - hasCss, - exampleCount, + components[pascalName] = pageEntry + } + + for (const component of propComponents) { + if (!aliases[component]) { + aliases[component] = { + ...pageEntry, + component, + hasProps: hasPropsForComponent(component), + } } } } } - const componentIndex: ComponentIndex = { version, components } + // Existing page-derived keys win; aliases make Nav and FileUpload discoverable. + const componentIndex: ComponentIndex = { version, components: { ...aliases, ...components } } return new Response(JSON.stringify(componentIndex), { status: 200, diff --git a/src/pages/api/index.ts b/src/pages/api/index.ts index db0d70e..95bfbfa 100644 --- a/src/pages/api/index.ts +++ b/src/pages/api/index.ts @@ -167,7 +167,7 @@ export const GET: APIRoute = async () => { path: '/api/{version}/{section}/{page}/props', method: 'GET', - description: 'Get props for a specific component. Automatically handles subsection-prefixed page names by removing the subsection prefix before lookup.', + description: 'Get props for a component. Use component to select a documented member of the page.', parameters: [ { name: 'version', @@ -191,6 +191,14 @@ export const GET: APIRoute = async () => description: 'Page ID (may be underscore-separated for subsection pages like "forms_checkbox"). Subsection prefix is automatically removed for props lookup.', example: 'alert or forms_checkbox', }, + { + name: 'component', + in: 'query', + required: false, + type: 'string', + description: 'Exact documented component name from component-index.', + example: 'NavItem', + }, ], returns: { type: 'array', diff --git a/src/pages/api/openapi.json.ts b/src/pages/api/openapi.json.ts index 68a33cd..5235c87 100644 --- a/src/pages/api/openapi.json.ts +++ b/src/pages/api/openapi.json.ts @@ -471,7 +471,7 @@ export const GET: APIRoute = async ({ url }) => { '/{version}/{section}/{page}/props': { get: { summary: 'Get component props', - description: 'Returns the props for the specified component. Automatically handles subsection-prefixed page names by removing the subsection prefix before lookup.', + description: 'Returns props for a page component or a documented member selected by component.', operationId: 'getProps', parameters: [ { @@ -505,6 +505,14 @@ export const GET: APIRoute = async ({ url }) => { }, example: 'alert or forms_checkbox', }, + { + name: 'component', + in: 'query', + required: false, + description: 'Exact documented component name from component-index.', + schema: { type: 'string' }, + example: 'NavItem', + }, ], responses: { '200': { diff --git a/src/utils/__tests__/componentIndex.test.ts b/src/utils/__tests__/componentIndex.test.ts new file mode 100644 index 0000000..d0da8bd --- /dev/null +++ b/src/utils/__tests__/componentIndex.test.ts @@ -0,0 +1,73 @@ +import { readFile } from 'fs/promises' +import { getCollection } from 'astro:content' +import { GET } from '../../pages/api/component-index.json' +import { generateApiIndex } from '../apiIndex/generate' + +jest.mock('fs/promises', () => ({ readFile: jest.fn() })) +jest.mock('astro:content', () => ({ getCollection: jest.fn() })) +jest.mock('../../content', () => ({ content: [{ name: 'react-component-docs', version: 'v6' }] })) +jest.mock('../getOutputDir', () => ({ getOutputDir: jest.fn().mockResolvedValue('/output') })) +jest.mock('../extractReactTokens', () => ({ extractReactTokens: jest.fn().mockResolvedValue([]) })) + +beforeEach(() => { + jest.clearAllMocks() + jest.spyOn(console, 'log').mockImplementation(() => {}) + ;(getCollection as jest.Mock).mockResolvedValue([ + { data: { section: 'components', id: 'Navigation', tab: 'react', propComponents: ['Nav', 'NavItem', 'NavMissingProps'] }, body: '', filePath: '/components/Nav.md' }, + { data: { section: 'components', subsection: 'file-upload', id: 'Simple file upload', tab: 'react', propComponents: ['FileUpload', 'FileUploadField'] }, body: '', filePath: '/components/FileUpload.md' }, + { data: { section: 'components', id: 'Form', tab: 'react', propComponents: ['ActionGroup', 'Form'] }, body: '', filePath: '/components/Form.md' }, + { data: { section: 'components', id: 'Navigation', tab: 'html' }, body: '', filePath: '/html/Nav.md' }, + { data: { section: 'components', id: 'Navigation', tab: 'react', propComponents: ['OldNav'] }, body: '', filePath: '/deprecated/components/Nav.md' }, + { data: { section: 'components', id: 'Chip', tab: 'react-deprecated', propComponents: ['Chip', 'ChipGroup'] }, body: '', filePath: '/deprecated/components/Chip.md' }, + { data: { section: 'components', id: 'Table', tab: 'react', propComponents: ['Table'] }, body: '', filePath: '/components/Table.md' }, + { data: { section: 'extensions', subsection: 'data-view', id: 'Table', tab: 'react', propComponents: ['DataViewTable'] }, body: '', filePath: '/extensions/DataViewTable.md' }, + { data: { section: 'foundations-and-styles', subsection: 'layouts', id: 'Flex', tab: 'react', propComponents: ['Flex', 'FlexItem'] }, body: '', filePath: '/layouts/Flex.md' }, + ]) + ;(readFile as jest.Mock).mockResolvedValue(JSON.stringify(Object.fromEntries( + ['Nav', 'NavItem', 'FileUpload', 'FileUploadField', 'ActionGroup', 'Form', 'Flex', 'FlexItem', 'Chip-deprecated', 'ChipGroup-deprecated', 'Table', 'DataViewTable'].map((name) => [name, { name, props: [] }]), + ))) +}) + +afterEach(() => jest.restoreAllMocks()) + +it('retains active frontmatter mappings without HTML or deprecated entries overwriting them', async () => { + const index = await generateApiIndex() + expect(index.propComponents?.['v6::components::navigation']).toEqual(['Nav', 'NavItem', 'NavMissingProps']) + expect(index.propComponents?.['v6::components::chip']).toEqual(['Chip', 'ChipGroup']) + expect(index.propComponents?.['v6::foundations-and-styles::layouts_flex']).toEqual(['Flex', 'FlexItem']) + expect(index.examples['v6::foundations-and-styles::layouts_flex::react']).toEqual([{ exampleName: 'FlexBasic', title: null }]) +}) + +it('indexes current collections, preserving page keys and exposing documented members', async () => { + const response = await GET({} as any) + const { components } = await response.json() + expect(response.status).toBe(200) + expect(components.Navigation).toMatchObject({ page: 'navigation', component: 'Nav', hasProps: true }) + expect(components.Nav).toMatchObject({ page: 'navigation', component: 'Nav' }) + expect(components.NavItem).toMatchObject({ page: 'navigation', component: 'NavItem', hasProps: true }) + expect(components.NavMissingProps).toMatchObject({ page: 'navigation', component: 'NavMissingProps', hasProps: false }) + expect(components.FileUpload).toMatchObject({ page: 'file-upload_simple-file-upload', component: 'FileUpload' }) + expect(components.FileUploadField).toMatchObject({ page: 'file-upload_simple-file-upload', component: 'FileUploadField' }) + expect(components.Chip).toMatchObject({ page: 'chip', component: 'Chip', hasProps: true }) + expect(components.ChipGroup).toMatchObject({ page: 'chip', component: 'ChipGroup', hasProps: true }) + expect(components.DataViewTable).toMatchObject({ page: 'data-view_table', component: 'DataViewTable', hasProps: true }) + expect(components.Flex).toMatchObject({ page: 'layouts_flex', tabs: ['react'], exampleCount: 1 }) + expect(components.Form).toMatchObject({ hasProps: true }) + // Only props are read from disk: an old apiIndex.json cannot seed the new index. + expect(readFile).toHaveBeenCalledTimes(1) + expect(readFile).toHaveBeenCalledWith('/output/props.json', 'utf-8') +}) + +it('only advertises deprecated props when the suffixed record exists', async () => { + ;(readFile as jest.Mock).mockResolvedValue(JSON.stringify({ + Chip: { name: 'Chip', props: [] }, + 'ChipGroup-deprecated': { name: 'ChipGroup', props: [] }, + })) + + const response = await GET({} as any) + const { components } = await response.json() + + expect(response.status).toBe(200) + expect(components.Chip).toMatchObject({ page: 'chip', hasProps: false }) + expect(components.ChipGroup).toMatchObject({ page: 'chip', hasProps: true }) +}) diff --git a/src/utils/apiIndex/generate.ts b/src/utils/apiIndex/generate.ts index e50493f..3ce9f9b 100644 --- a/src/utils/apiIndex/generate.ts +++ b/src/utils/apiIndex/generate.ts @@ -57,6 +57,8 @@ export interface ApiIndex { * (e.g., { 'v6::components::alert': ['react'], 'v6::components::forms_checkbox': ['react'] }) */ tabs: Record + /** Documented React props members by version::section::page, in frontmatter order. */ + propComponents?: Record /** Examples by version::section::page::tab with titles * (e.g., { 'v6::components::alert::react': [{exampleName: 'AlertDefault', title: 'Default alert'}] }) */ @@ -119,6 +121,7 @@ export async function generateApiIndex(): Promise { sections: {}, pages: {}, tabs: {}, + propComponents: {}, examples: {}, css: {}, } @@ -151,6 +154,7 @@ export async function generateApiIndex(): Promise { const tabExamples: Record = {} const pageCss: Record = {} const pageCssPrefixes: Record = {} + const deprecatedPropComponents: Record = {} flatEntries.forEach((entry: any) => { const { section, subsection, id } = entry.data @@ -185,6 +189,13 @@ export async function generateApiIndex(): Promise { } pageTabs[tabKey].add(tab) + // Prefer active React members, but retain deprecated-only pages such as Chip. + if (tab === 'react' && entry.data.propComponents?.length) { + index.propComponents![tabKey] = [...new Set(entry.data.propComponents)] + } else if (tab === 'react-deprecated' && entry.data.propComponents?.length) { + deprecatedPropComponents[tabKey] = [...new Set(entry.data.propComponents)] + } + // Collect examples for this tab const exampleKey = `${tabKey}::${tab}` const examplesWithTitles = extractExamplesWithTitles(entry.body || '') @@ -199,6 +210,12 @@ export async function generateApiIndex(): Promise { } }) + Object.entries(deprecatedPropComponents).forEach(([key, propComponents]) => { + if (!index.propComponents![key]) { + index.propComponents![key] = propComponents + } + }) + // Extract CSS tokens for pages that have cssPrefix for (const [pageKey, cssPrefix] of Object.entries(pageCssPrefixes)) { try { diff --git a/src/utils/apiIndex/props.ts b/src/utils/apiIndex/props.ts new file mode 100644 index 0000000..9cc614e --- /dev/null +++ b/src/utils/apiIndex/props.ts @@ -0,0 +1,8 @@ +import { pascalCase } from 'change-case' +import { removeSubsection } from '../case' + +/** Preserve page-name lookups, using frontmatter when the display name differs. */ +export function getPrimaryPropComponent(page: string, propComponents: string[] = []): string { + const pageName = pascalCase(removeSubsection(page)) + return propComponents.includes(pageName) ? pageName : propComponents[0] || pageName +}