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) => ;
+ 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
+}