Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,7 @@ src/apiIndex.json
textContent/*.mdx

coverage/

.wrangler/

temp
1 change: 1 addition & 0 deletions README.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ Any static assets, like images, can be placed in the `public/` directory.

To define the markdown schema this project uses a typescript based schema known as [Zod](https://zod.dev). Details of how this is integratred into Astro can be found in Astros documentation on [content creation using Zod](https://docs.astro.build/en/guides/content-collections/#defining-datatypes-with-zod).

Note: When running in dev mode locally on a clean repository, API endpoints will not be available until you run `npm run build` to generate the API index.
### 🧞 Commands

All commands are run from the root of the project, from a terminal:
Expand Down
89 changes: 54 additions & 35 deletions cli/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ try {
.replace('file://', '')
} catch (e: any) {
if (e.code === 'ERR_MODULE_NOT_FOUND') {
console.log('@patternfly/patternfly-doc-core not found, using current directory as astroRoot')
astroRoot = process.cwd()
} else {
console.error('Error resolving astroRoot', e)
Expand Down Expand Up @@ -87,29 +88,33 @@ async function transformMDContentToMDX() {
}
}

async function initializeApiIndex() {
async function initializeApiIndex(program: Command) {
const { verbose } = program.opts()
const templateIndexPath = join(astroRoot, 'cli', 'templates', 'apiIndex.json')
const targetIndexPath = join(astroRoot, 'src', 'apiIndex.json')

const targetIndexPath = join(absoluteOutputDir, 'apiIndex.json')
const indexExists = await fileExists(targetIndexPath)

// early return if the file exists from a previous build
if (indexExists) {
console.log('apiIndex.json already exists, skipping initialization')
if (verbose) {
console.log('apiIndex.json already exists, skipping initialization')
}
return
}

try {
await copyFile(templateIndexPath, targetIndexPath)
console.log('Initialized apiIndex.json')
if (verbose) {
console.log('Initialized apiIndex.json')
}
} catch (e: any) {
console.error('Error copying apiIndex.json template:', e)
}
}

async function buildProject(): Promise<DocsConfig | undefined> {
await updateContent(program)
await generateProps(program, true)
async function buildProject(program: Command): Promise<DocsConfig | undefined> {
const { verbose } = program.opts()

if (!config) {
console.error(
'No config found, please run the `setup` command or manually create a pf-docs.config.mjs file',
Expand All @@ -123,44 +128,57 @@ async function buildProject(): Promise<DocsConfig | undefined> {
)
return config
}

await initializeApiIndex()
await updateContent(program)
await generateProps(program, true)
await initializeApiIndex(program)
await transformMDContentToMDX()

build({
const docsOutputDir = join(absoluteOutputDir, 'docs')

await build({
root: astroRoot,
outDir: join(absoluteOutputDir, 'docs'),
outDir: docsOutputDir,
})

// copy the apiIndex.json file to the docs directory so it can be served as a static asset
try {
const apiIndexPath = join(absoluteOutputDir, 'apiIndex.json')
const docsApiIndexPath = join(absoluteOutputDir, 'docs', 'apiIndex.json')
await copyFile(apiIndexPath, docsApiIndexPath)

if (verbose) {
console.log('Copied apiIndex.json to docs directory')
}
} catch (error) {
console.error('Failed to copy apiIndex.json to docs directory:', error)
throw error
}

return config
}

async function deploy() {
const { verbose } = program.opts()
async function deploy(program: Command) {
const { verbose, dryRun } = program.opts()

if (verbose) {
console.log('Starting Cloudflare deployment...')
}

if (dryRun) {
console.log('Dry run mode enabled, skipping deployment')
return
}

try {
// First build the project
const config = await buildProject()
if (config) {
if (verbose) {
console.log('Build complete, deploying to Cloudflare...')
}

// Deploy using Wrangler
const { execSync } = await import('child_process')
const outputPath = join(absoluteOutputDir, 'docs')

execSync(`npx wrangler pages deploy ${outputPath}`, {
stdio: 'inherit',
cwd: currentDir,
})

console.log('Successfully deployed to Cloudflare Pages!')
}
// Deploy using Wrangler
const { execSync } = await import('child_process')

execSync(`wrangler pages deploy`, {
stdio: 'inherit',
cwd: currentDir,
})
Comment on lines 172 to +179
Copy link

@coderabbitai coderabbitai bot Dec 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# Check for wrangler.toml files and examine the deploy function in detail
echo "=== Finding wrangler.toml files ==="
fd -t f "wrangler.toml"

echo -e "\n=== Checking cli/cli.ts deploy function context ==="
# Read the deploy function in full (lines 155-181 as mentioned in comment)
if [ -f "cli/cli.ts" ]; then
  sed -n '155,181p' cli/cli.ts
else
  echo "cli/cli.ts not found, searching for it"
  fd -t f "cli.ts"
fi

Repository: patternfly/patternfly-doc-core

Length of output: 761


🏁 Script executed:

echo "=== Searching for currentDir definition in cli/cli.ts ==="
rg "currentDir" cli/cli.ts -B 2 -A 2

echo -e "\n=== Checking full deploy function and surrounding context ==="
wc -l cli/cli.ts

echo -e "\n=== Search for where deploy() is called ==="
rg "deploy\(" cli/cli.ts -B 3 -A 3

echo -e "\n=== Check if outputDir is used in deploy or related functions ==="
rg "outputDir" cli/cli.ts -B 1 -A 1

Repository: patternfly/patternfly-doc-core

Length of output: 1997


🏁 Script executed:

echo "=== Search for README or deployment documentation ==="
fd -t f -i "readme" | head -5

echo -e "\n=== Check for any comments/docs around deploy function ==="
sed -n '145,180p' cli/cli.ts

echo -e "\n=== Search for test files related to deploy ==="
fd -t f "test\|spec" | grep -i deploy

echo -e "\n=== Check if there are any example configs or docs ==="
fd -t f "example\|sample\|template" | head -10

echo -e "\n=== Search for wrangler usage in comments or docs ==="
rg "wrangler" --type md --type txt

Repository: patternfly/patternfly-doc-core

Length of output: 1180


Pass the build output directory explicitly to Wrangler.

The deployment command executes wrangler pages deploy without specifying which directory to deploy. Since docs are built to absoluteOutputDir (derived from config.outputDir), but this path is not passed to the deploy function, the deploy command needs to be updated to:

wrangler pages deploy ${absoluteOutputDir}

Or the deploy() function should receive absoluteOutputDir as a parameter and pass it to the wrangler command. Without this, Wrangler may deploy from the wrong directory or fail if the consumer's wrangler.toml is not correctly configured.

🤖 Prompt for AI Agents
In cli/cli.ts around lines 167-174, the wrangler deploy call does not specify
the build output directory; update the execSync invocation to pass the resolved
absoluteOutputDir to Wrangler (e.g., run "wrangler pages deploy
<absoluteOutputDir>") or change the surrounding deploy() function to accept
absoluteOutputDir and forward it to execSync; ensure you use the
already-imported execSync, pass cwd appropriately or quote the path to handle
spaces, and validate absoluteOutputDir is resolved before calling execSync.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is intended, we want the deploy dir to come from the wrangler config file.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@wise-king-sullyman, understood! Using the wrangler.toml configuration for the deployment directory is a valid approach and keeps the deployment configuration centralized. Thank you for clarifying the intent.


console.log('Successfully deployed to Cloudflare Pages!')
} catch (error) {
console.error('Deployment failed:', error)
process.exit(1)
Expand All @@ -172,6 +190,7 @@ program.name('pf-doc-core')

program.option('--verbose', 'verbose mode', false)
program.option('--props', 'generate props data', false)
program.option('--dry-run', 'dry run mode', false)

program.command('setup').action(async () => {
await Promise.all([
Expand All @@ -194,7 +213,7 @@ program.command('init').action(async () => {

program.command('start').action(async () => {
await updateContent(program)
await initializeApiIndex()
await initializeApiIndex(program)

// if a props file hasn't been generated yet, but the consumer has propsData, it will cause a runtime error so to
// prevent that we're just creating a props file regardless of what they say if one doesn't exist yet
Expand All @@ -204,7 +223,7 @@ program.command('start').action(async () => {
})

program.command('build').action(async () => {
await buildProject()
await buildProject(program)
})

program.command('generate-props').action(async () => {
Expand All @@ -229,7 +248,7 @@ program
})

program.command('deploy').action(async () => {
await deploy()
await deploy(program)
})

program.parse(process.argv)
2 changes: 1 addition & 1 deletion jest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@ const config: Config = {
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
moduleNameMapper: {
'\\.(css|less)$': '<rootDir>/src/__mocks__/styleMock.ts',
'(.+)\\.js': '$1',
'^astro:content$': '<rootDir>/src/__mocks__/astro-content.ts',
'(.+)\\.js': '$1',
},
setupFilesAfterEnv: ['<rootDir>/test.setup.ts'],
transformIgnorePatterns: [
Expand Down
2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 4 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,15 @@
"build:props": "npm run build:cli && node ./dist/cli/cli.js generate-props",
"preview": "wrangler pages dev",
"astro": "astro",
"deploy": "wrangler pages deploy",
"deploy": "npm run build:cli && node ./dist/cli/cli.js deploy",
"versions:upload": "wrangler versions upload",
"prettier": "prettier --write ./src",
"lint": "eslint . --cache --cache-strategy content",
"test": "jest",
"test:watch": "jest --watch",
"semantic-release": "semantic-release",
"cf-typegen": "wrangler types"
"cf-typegen": "wrangler types",
"clean": "rm -rf dist .astro .wrangler"
},
"main": "dist/cli/cli.js",
"bin": "./dist/cli/cli.js",
Expand Down Expand Up @@ -64,7 +65,7 @@
"@patternfly/quickstarts": "^6.0.0",
"@types/react": "^18.3.23",
"@types/react-dom": "^18.3.7",
"astro": "5.15.9",
"astro": "^5.15.9",
"change-case": "5.4.4",
"commander": "^13.1.0",
"glob": "^11.0.3",
Expand Down
86 changes: 77 additions & 9 deletions src/__tests__/pages/api/__tests__/[version].test.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,26 @@
import { GET } from '../../../../pages/api/[version]'

/**
* Mock apiIndex.json with multiple versions (v5, v6)
* to test section retrieval for different versions
*/
jest.mock('../../../../apiIndex.json', () => ({
const mockApiIndex = {
versions: ['v5', 'v6'],
sections: {
v5: ['getting-started'],
v6: ['components', 'layouts', 'utilities'],
},
pages: {},
tabs: {},
}))
}

it('returns all sections for a valid version', async () => {
global.fetch = jest.fn(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve(mockApiIndex),
} as Response)
)

const response = await GET({
params: { version: 'v6' },
url: new URL('http://localhost:4321/api/v6'),
} as any)
const body = await response.json()

Expand All @@ -26,67 +30,131 @@ it('returns all sections for a valid version', async () => {
expect(body).toContain('components')
expect(body).toContain('layouts')
expect(body).toContain('utilities')

jest.restoreAllMocks()
})

it('returns only sections for the requested version', async () => {
global.fetch = jest.fn(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve(mockApiIndex),
} as Response)
)

const response = await GET({
params: { version: 'v5' },
url: new URL('http://localhost:4321/api/v5'),
} as any)
const body = await response.json()

expect(response.status).toBe(200)
expect(body).toContain('getting-started')

jest.restoreAllMocks()
})

it('sorts sections alphabetically', async () => {
global.fetch = jest.fn(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve(mockApiIndex),
} as Response)
)

const response = await GET({
params: { version: 'v6' },
url: new URL('http://localhost:4321/api/v6'),
} as any)
const body = await response.json()

const sorted = [...body].sort()
expect(body).toEqual(sorted)

jest.restoreAllMocks()
})

it('deduplicates sections from multiple collections', async () => {
global.fetch = jest.fn(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve(mockApiIndex),
} as Response)
)

const response = await GET({
params: { version: 'v6' },
url: new URL('http://localhost:4321/api/v6'),
} as any)
const body = await response.json()

const unique = [...new Set(body)]
expect(body).toEqual(unique)

jest.restoreAllMocks()
})

it('returns 404 error for nonexistent version', async () => {
global.fetch = jest.fn(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve(mockApiIndex),
} as Response)
)

const response = await GET({
params: { version: 'v99' },
url: new URL('http://localhost:4321/api/v99'),
} as any)
const body = await response.json()

expect(response.status).toBe(404)
expect(body).toHaveProperty('error')
expect(body.error).toContain('v99')
expect(body.error).toContain('not found')

jest.restoreAllMocks()
})

it('returns 400 error when version parameter is missing', async () => {
global.fetch = jest.fn(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve(mockApiIndex),
} as Response)
)

const response = await GET({
params: {},
url: new URL('http://localhost:4321/api/'),
} as any)
const body = await response.json()

expect(response.status).toBe(400)
expect(body).toHaveProperty('error')
expect(body.error).toContain('Version parameter is required')

jest.restoreAllMocks()
})

it('excludes content entries that have no section field', async () => {
it('returns sections array that matches the API index', async () => {
global.fetch = jest.fn(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve(mockApiIndex),
} as Response)
)

const response = await GET({
params: { version: 'v6' },
url: new URL('http://localhost:4321/api/v6'),
} as any)
const body = await response.json()

// Should only include sections from entries that have data.section
expect(body.length).toBeGreaterThan(0)
// Verify the returned sections exactly match the indexed sections
// The API index generation process filters out entries without section fields
expect(body).toEqual(mockApiIndex.sections.v6)
expect(body).toEqual(['components', 'layouts', 'utilities'])

jest.restoreAllMocks()
})
Loading