Skip to content

Commit 9343d0d

Browse files
feat(API): Create CLI (#7)
* feat(API): Create CLI * Fix dynamic content types * Add CLI building to CI * feat(cli): automate setup process * fix(cli): change config file extension to mjs * fix(CLI): ensure that the config exists before doing a build
1 parent 4078e8f commit 9343d0d

27 files changed

+1149
-40
lines changed

.github/workflows/build.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,5 +28,6 @@ jobs:
2828
cache: 'npm'
2929
- run: npm ci
3030
- run: npm run build --if-present
31+
- run: npm run build:cli
3132
- run: npm test
3233
- run: npm run lint

.github/workflows/release.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,5 +22,7 @@ jobs:
2222
registry-url: https://registry.npmjs.org/
2323
- name: Build dist
2424
run: npm build
25+
- name: Build CLI
26+
run: npm run build:cli
2527
- name: Release to NPM
2628
run: npx semantic-release

.npmignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
__tests__

README.md

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,38 @@
22

33
Patternfly documentation core contains the base packages needed to build and release the PatternFly org website.
44

5-
## Development
5+
## Consuming this repo as a package
6+
7+
### Setup
8+
9+
Using this package for your documentation is accomplished in just a few simple steps:
10+
11+
1. Run `npx patternfly-doc-core@latest setup` from the root of your repo. This will:
12+
- add the documentation core as a dependency in your package
13+
- add the relevant scripts for using the documentation core to your package scripts
14+
- create the configuration file for customizing the documentation core
15+
1. Install the documentation core using your projects dependency manager, e.g. `npm install` or `yarn install`
16+
1. Run the initialization script using your script runner, e.g. `npm run init:docs` or `yarn init:docs`
17+
- this will update a Vite config in the documentation so that it can access the files in your repo when running the development server
18+
1. Edit the `pf-docs.config.mjs` file in your project root to point the documentation core to your documentation files
19+
20+
### Use
21+
22+
Once setup is complete you can start the dev server with the `start` script, and create production builds using the `build:docs` script!
23+
24+
## Running this repo directly
25+
26+
### Development
627

728
The website is built using [Astro](https://astro.build). Astro looks for `.astro` or `.md` files in the `src/pages/` directory. Each page is exposed as a route based on its file name.
829

9-
The `src/components/` folder contains Astro and React components that can be used to build the websites pages.
30+
The `src/components/` folder contains Astro and React components that can be used to build the websites pages.
1031

1132
Any static assets, like images, can be placed in the `public/` directory.
1233

1334
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).
1435

15-
## 🧞 Commands
36+
### 🧞 Commands
1637

1738
All commands are run from the root of the project, from a terminal:
1839

@@ -24,3 +45,5 @@ All commands are run from the root of the project, from a terminal:
2445
| `npm run preview` | Preview your build locally, before deploying |
2546
| `npm run astro ...` | Run CLI commands like `astro add`, `astro check` |
2647
| `npm run astro -- --help` | Get help using the Astro CLI |
48+
| `npm run build:cli` | Create a JS build of the documentation core CLI |
49+
| `npm run build:cli:watch` | Run the CLI builder in watch mode |

astro.config.mjs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,11 @@ export default defineConfig({
88
vite: {
99
ssr: {
1010
noExternal: ["@patternfly/*", "react-dropzone"],
11+
},
12+
server: {
13+
fs: {
14+
allow: ['./']
15+
}
1116
}
1217
}
1318
});
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import { createCollectionContent } from '../createCollectionContent'
2+
import { getConfig } from '../getConfig'
3+
import { writeFile } from 'fs/promises'
4+
5+
jest.mock('../getConfig')
6+
jest.mock('fs/promises')
7+
8+
// suppress console.log so that it doesn't clutter the test output
9+
jest.spyOn(console, 'log').mockImplementation(() => {})
10+
11+
it('should call getConfig with the passed config file location', async () => {
12+
await createCollectionContent('/foo/', 'bar', false)
13+
14+
expect(getConfig).toHaveBeenCalledWith('bar')
15+
})
16+
17+
it('should not proceed if config is not found', async () => {
18+
;(getConfig as jest.Mock).mockResolvedValue(undefined)
19+
20+
await createCollectionContent('/foo/', 'bar', false)
21+
22+
expect(writeFile).not.toHaveBeenCalled()
23+
})
24+
25+
it('should log error if content is not found in config', async () => {
26+
;(getConfig as jest.Mock).mockResolvedValue({ foo: 'bar' })
27+
28+
const mockConsoleError = jest.fn()
29+
jest.spyOn(console, 'error').mockImplementation(mockConsoleError)
30+
31+
await createCollectionContent('/foo/', 'bar', false)
32+
33+
expect(mockConsoleError).toHaveBeenCalledWith('No content found in config')
34+
expect(writeFile).not.toHaveBeenCalled()
35+
})
36+
37+
it('should call writeFile with the expected file location and content without throwing any errors', async () => {
38+
;(getConfig as jest.Mock).mockResolvedValue({ content: { key: 'value' } })
39+
40+
const mockConsoleError = jest.fn()
41+
jest.spyOn(console, 'error').mockImplementation(mockConsoleError)
42+
43+
await createCollectionContent('/foo/', 'bar', false)
44+
45+
expect(writeFile).toHaveBeenCalledWith(
46+
'/foo/src/content.ts',
47+
`export const content = ${JSON.stringify({ key: 'value' })}`,
48+
)
49+
expect(mockConsoleError).not.toHaveBeenCalled()
50+
})
51+
52+
it('should log error if writeFile throws an error', async () => {
53+
;(getConfig as jest.Mock).mockResolvedValue({ content: { key: 'value' } })
54+
55+
const mockConsoleError = jest.fn()
56+
jest.spyOn(console, 'error').mockImplementation(mockConsoleError)
57+
58+
const error = new Error('error')
59+
;(writeFile as jest.Mock).mockRejectedValue(error)
60+
61+
await createCollectionContent('/foo/', 'bar', false)
62+
63+
expect(mockConsoleError).toHaveBeenCalledWith(
64+
'Error writing content file',
65+
error,
66+
)
67+
})
68+
69+
it('should log that content file was created when run in verbose mode', async () => {
70+
const mockConsoleLog = jest.fn()
71+
jest.spyOn(console, 'log').mockImplementation(mockConsoleLog)
72+
73+
await createCollectionContent('/foo/', 'bar', true)
74+
75+
expect(mockConsoleLog).toHaveBeenCalledWith('Content file created')
76+
})
77+
78+
it('should not log that content file was created when not run in verbose mode', async () => {
79+
const mockConsoleLog = jest.fn()
80+
jest.spyOn(console, 'log').mockImplementation(mockConsoleLog)
81+
82+
await createCollectionContent('/foo/', 'bar', false)
83+
84+
expect(mockConsoleLog).not.toHaveBeenCalled()
85+
})
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
/* eslint-disable no-console */
2+
3+
import { createConfigFile } from '../createConfigFile.ts'
4+
import { access, copyFile } from 'fs/promises'
5+
6+
jest.mock('fs/promises')
7+
8+
afterEach(() => {
9+
jest.clearAllMocks()
10+
})
11+
12+
// suppress console calls so that it doesn't clutter the test output
13+
jest.spyOn(console, 'log').mockImplementation(() => {})
14+
jest.spyOn(console, 'error').mockImplementation(() => {})
15+
16+
it('should log a message and not call copyFile if the config file already exists', async () => {
17+
;(access as jest.Mock).mockResolvedValue(true)
18+
19+
await createConfigFile('/astro', '/consumer')
20+
21+
expect(copyFile).not.toHaveBeenCalled()
22+
expect(console.log).toHaveBeenCalledWith(
23+
'pf-docs.config.mjs already exists, proceeding to next setup step',
24+
)
25+
})
26+
27+
it('should copy the template file if the config file does not exist', async () => {
28+
;(access as jest.Mock).mockRejectedValue(new Error())
29+
;(copyFile as jest.Mock).mockResolvedValue(undefined)
30+
31+
const from = '/astro/cli/templates/pf-docs.config.mjs'
32+
const to = '/consumer/pf-docs.config.mjs'
33+
34+
await createConfigFile('/astro', '/consumer')
35+
36+
expect(copyFile).toHaveBeenCalledWith(from, to)
37+
expect(console.log).toHaveBeenCalledWith(
38+
'pf-docs.config.mjs has been created in /consumer',
39+
)
40+
})
41+
42+
it('should log an error if copyFile fails', async () => {
43+
;(access as jest.Mock).mockRejectedValue(new Error())
44+
;(copyFile as jest.Mock).mockRejectedValue(new Error('copy failed'))
45+
46+
await createConfigFile('/astro', '/consumer')
47+
48+
expect(console.error).toHaveBeenCalledWith(
49+
'Error creating pf-docs.config.mjs in /consumer.',
50+
)
51+
expect(console.error).toHaveBeenCalledWith(new Error('copy failed'))
52+
})

cli/__tests__/getConfig.test.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { getConfig } from '../getConfig'
2+
import { resolve } from 'path'
3+
4+
it('should return the config when pf-docs.config.mjs exists', async () => {
5+
const config = await getConfig(resolve('./cli/testData/good.config.js'))
6+
expect(config).toEqual({
7+
config: {
8+
content: [
9+
{
10+
base: 'base-path',
11+
packageName: 'package-name',
12+
pattern: 'pattern',
13+
name: 'name',
14+
},
15+
],
16+
},
17+
})
18+
})
19+
20+
it('should return undefined and log error when pf-docs.config.mjs does not exist', async () => {
21+
const consoleErrorMock = jest.fn()
22+
23+
jest.spyOn(console, 'error').mockImplementation(consoleErrorMock)
24+
25+
const config = await getConfig('foo')
26+
expect(config).toBeUndefined()
27+
expect(consoleErrorMock).toHaveBeenCalledWith(
28+
'pf-docs.config.mjs not found, have you created it at the root of your package?',
29+
)
30+
})

cli/__tests__/setFsRootDir.test.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { readFile, writeFile } from 'fs/promises'
2+
import { setFsRootDir } from '../setFsRootDir'
3+
4+
jest.mock('fs/promises')
5+
6+
// suppress console.log so that it doesn't clutter the test output
7+
jest.spyOn(console, 'log').mockImplementation(() => {})
8+
9+
it('should attempt to read the astro config file', async () => {
10+
;(readFile as jest.Mock).mockResolvedValue("{ fs: { allow: ['/bar/'] } }")
11+
12+
await setFsRootDir('/foo/', '/bar')
13+
14+
expect(readFile).toHaveBeenCalledWith('/foo/astro.config.mjs', 'utf8')
15+
})
16+
17+
it('should not modify the file if the default allow list is not present', async () => {
18+
;(readFile as jest.Mock).mockResolvedValue("{ fs: { allow: ['/bar/'] } }")
19+
20+
await setFsRootDir('/foo/', '/bar')
21+
22+
expect(writeFile).not.toHaveBeenCalled()
23+
})
24+
25+
it('should modify the file if the default allow list is present', async () => {
26+
;(readFile as jest.Mock).mockResolvedValue("{ fs: { allow: ['./'] } }")
27+
28+
await setFsRootDir('/foo/', '/bar')
29+
30+
expect(writeFile).toHaveBeenCalledWith(
31+
'/foo/astro.config.mjs',
32+
"{ fs: { allow: ['/bar/'] } }",
33+
)
34+
})
35+
36+
it('should log an error if writing the file fails', async () => {
37+
;(readFile as jest.Mock).mockResolvedValue("{ fs: { allow: ['./'] } }")
38+
;(writeFile as jest.Mock).mockRejectedValue(new Error('write error'))
39+
const consoleErrorSpy = jest
40+
.spyOn(console, 'error')
41+
.mockImplementation(() => {})
42+
43+
await setFsRootDir('/foo/', '/bar')
44+
45+
expect(consoleErrorSpy).toHaveBeenCalledWith(
46+
`Error setting the server allow list in /foo/`,
47+
expect.any(Error),
48+
)
49+
})
50+
51+
it('should log a success message after attempting to write the file', async () => {
52+
;(readFile as jest.Mock).mockResolvedValue("{ fs: { allow: ['./'] } }")
53+
const consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(() => {})
54+
55+
await setFsRootDir('/foo/', '/bar')
56+
57+
expect(consoleLogSpy).toHaveBeenCalledWith('fs value set created')
58+
})

0 commit comments

Comments
 (0)