Skip to content
73 changes: 72 additions & 1 deletion cli/__tests__/convertToMDX.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<LiveExample src={${name}} />`)
})

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('<LiveExample src={Example_2} />')
})

it('should convert a file with HTML examples', async () => {
const mockContent = '# Test Content\n```html\n<div>Test HTML</div>\n```'
const expectedContent =
Expand Down Expand Up @@ -160,4 +232,3 @@ it('should preserve HTML comments in HTML files', async () => {
expectedHTMLContent,
)
})

51 changes: 51 additions & 0 deletions cli/__tests__/tsDocGen.test.ts
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement>;
}
export const InputGroupBase = ({ children, innerRef }: InputGroupProps) =>
<div ref={innerRef}>{children}</div>;
InputGroupBase.displayName = 'InputGroupBase';
export const InputGroup = forwardRef((props: InputGroupProps, ref: React.Ref<HTMLDivElement>) =>
<InputGroupBase {...props} innerRef={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) => <footer>{label}</footer>;
export const Wrapper = ({ children }: WrapperProps) => <div>{children}</div>;
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 }),
])
})
2 changes: 1 addition & 1 deletion cli/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
41 changes: 36 additions & 5 deletions cli/convertToMDX.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>()
const names = new Set<string>()

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<LiveExample src={$1} />`
return content.replace(ExampleBlockRegex, replacementString)
return `${importStatement}\n<LiveExample src={${name}} />`
})
}

async function handleHTMLExamples(
Expand Down
7 changes: 5 additions & 2 deletions cli/tsDocGen.js
Original file line number Diff line number Diff line change
@@ -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 = [
Expand Down Expand Up @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion jest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ const config: Config = {
moduleNameMapper: {
'\\.(css|less)$': '<rootDir>/src/__mocks__/styleMock.ts',
'^astro:content$': '<rootDir>/src/__mocks__/astro-content.ts',
'(.+)\\.js': '$1',
'(.+)\\.js$': '$1',
},
setupFilesAfterEnv: ['<rootDir>/test.setup.ts'],
transformIgnorePatterns: [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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),
}))
Expand Down Expand Up @@ -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 () => {
Expand Down
25 changes: 20 additions & 5 deletions src/pages/api/[version]/[section]/[page]/props.ts
Original file line number Diff line number Diff line change
@@ -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(
Expand All @@ -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,
)
}
Expand Down
Loading
Loading