fix(templates): update template/plugin and fix import map issue (#12305)

### What?
1. Adds logic to automatically update the `importMap.js` file with the
project name provided by the user.
2. Adds an updated version of the `README.md` file that we had when this
template existed outside of the monorepo
([here](https://github.com/payloadcms/plugin-template/blob/main/README.md))
to provide clear instructions of required steps.

### Why?
1. The plugin template when installed via `npx create-payload-app` asks
the user for a project name, however the exports from `importMap.js` do
not get updated to the provided name. This throws errors when running
the project and prevents it from building.

2. The `/dev` folder requires the `.env.example` to be copied and
renamed to `.env` - the project will not run until this is done. The
template lacks instructions that this is a required step.

### How?
1. Updates
`packages/create-payload-app/src/lib/configure-plugin-project.ts` to
read the `importMap.js` file and replace the placeholder plugin name
with the name provided by the users. Adds a test to
`packages/create-payload-app/src/lib/create-project.spec.ts` to verify
that this file gets updated correctly.
2. Adds instructions on using this template to the `README.md` file,
ensuring key steps (like adding the `.env` file) are clearly stated.

Additional housekeeping updates:
- Removed Jest and replaced it with Vitest for testing
- Updated the base test approach to use Vitest instead of Jest
- Removed `NextRESTClient` in favor of directly creating Request objects
- Abstracted `getCustomEndpointHandler` function
- Added ensureIndexes: true to the mongooseAdapter configuration
- Removed the custom server from the dev folder
- Updated the pnpm dev script to "dev": "next dev dev --turbo"
- Removed `admin.autoLogin`

Fixes #12198
This commit is contained in:
Jessica Rynkar
2025-05-27 22:33:23 +01:00
committed by GitHub
parent 20f7017758
commit 68ba24d91f
18 changed files with 450 additions and 454 deletions

View File

@@ -16,12 +16,15 @@ export const configurePluginProject = ({
const devPayloadConfigPath = path.resolve(projectDirPath, './dev/payload.config.ts')
const devTsConfigPath = path.resolve(projectDirPath, './dev/tsconfig.json')
const indexTsPath = path.resolve(projectDirPath, './src/index.ts')
const devImportMapPath = path.resolve(projectDirPath, './dev/app/(payload)/admin/importMap.js')
const devPayloadConfig = fse.readFileSync(devPayloadConfigPath, 'utf8')
const devTsConfig = fse.readFileSync(devTsConfigPath, 'utf8')
const indexTs = fse.readFileSync(indexTsPath, 'utf-8')
const devImportMap = fse.readFileSync(devImportMapPath, 'utf-8')
const updatedTsConfig = devTsConfig.replaceAll('plugin-package-name-placeholder', projectName)
const updatedImportMap = devImportMap.replaceAll('plugin-package-name-placeholder', projectName)
let updatedIndexTs = indexTs.replaceAll('plugin-package-name-placeholder', projectName)
const pluginExportVariableName = toCamelCase(projectName)
@@ -43,4 +46,5 @@ export const configurePluginProject = ({
fse.writeFileSync(devPayloadConfigPath, updatedPayloadConfig)
fse.writeFileSync(devTsConfigPath, updatedTsConfig)
fse.writeFileSync(indexTsPath, updatedIndexTs)
fse.writeFileSync(devImportMapPath, updatedImportMap)
}

View File

@@ -63,6 +63,30 @@ describe('createProject', () => {
expect(packageJson.name).toStrictEqual(projectName)
})
it('updates project name in plugin template importMap file', async () => {
const projectName = 'my-custom-plugin'
const template: ProjectTemplate = {
name: 'plugin',
type: 'plugin',
description: 'Template for creating a Payload plugin',
url: 'https://github.com/payloadcms/payload/templates/plugin',
}
await createProject({
cliArgs: { ...args, '--local-template': 'plugin' } as CliArgs,
packageManager,
projectDir,
projectName,
template,
})
const importMapPath = path.resolve(projectDir, './dev/app/(payload)/admin/importMap.js')
const importMapFile = fse.readFileSync(importMapPath, 'utf-8')
expect(importMapFile).not.toContain('plugin-package-name-placeholder')
expect(importMapFile).toContain('my-custom-plugin')
})
it('creates example', async () => {
const projectName = 'custom-server-example'
const example: ProjectExample = {

View File

@@ -41,3 +41,9 @@ yarn-error.log*
.env
/dev/media
# Playwright
/test-results/
/playwright-report/
/blob-report/
/playwright/.cache/

View File

@@ -1 +1,218 @@
# Plugin
# Payload Plugin Template
A template repo to create a [Payload CMS](https://payloadcms.com) plugin.
Payload is built with a robust infrastructure intended to support Plugins with ease. This provides a simple, modular, and reusable way for developers to extend the core capabilities of Payload.
To build your own Payload plugin, all you need is:
- An understanding of the basic Payload concepts
- And some JavaScript/Typescript experience
## Background
Here is a short recap on how to integrate plugins with Payload, to learn more visit the [plugin overview page](https://payloadcms.com/docs/plugins/overview).
### How to install a plugin
To install any plugin, simply add it to your payload.config() in the Plugin array.
```ts
import myPlugin from 'my-plugin'
export const config = buildConfig({
plugins: [
// You can pass options to the plugin
myPlugin({
enabled: true,
}),
],
})
```
### Initialization
The initialization process goes in the following order:
1. Incoming config is validated
2. **Plugins execute**
3. Default options are integrated
4. Sanitization cleans and validates data
5. Final config gets initialized
## Building the Plugin
When you build a plugin, you are purely building a feature for your project and then abstracting it outside of the project.
### Template Files
In the Payload [plugin template](https://github.com/payloadcms/payload/tree/main/templates/plugin), you will see a common file structure that is used across all plugins:
1. root folder
2. /src folder
3. /dev folder
#### Root
In the root folder, you will see various files that relate to the configuration of the plugin. We set up our environment in a similar manner in Payload core and across other projects, so hopefully these will look familiar:
- **README**.md\* - This contains instructions on how to use the template. When you are ready, update this to contain instructions on how to use your Plugin.
- **package**.json\* - Contains necessary scripts and dependencies. Overwrite the metadata in this file to describe your Plugin.
- .**eslint**.config.js - Eslint configuration for reporting on problematic patterns.
- .**gitignore** - List specific untracked files to omit from Git.
- .**prettierrc**.json - Configuration for Prettier code formatting.
- **tsconfig**.json - Configures the compiler options for TypeScript
- .**swcrc** - Configuration for SWC, a fast compiler that transpiles and bundles TypeScript.
- **vitest**.config.js - Config file for Vitest, defining how tests are run and how modules are resolved
**IMPORTANT\***: You will need to modify these files.
#### Dev
In the dev folder, youll find a basic payload project, created with `npx create-payload-app` and the blank template.
**IMPORTANT**: Make a copy of the `.env.example` file and rename it to `.env`. Update the `DATABASE_URI` to match the database you are using and your plugin name. Update `PAYLOAD_SECRET` to a unique string.
**You will not be able to run `pnpm/yarn dev` until you have created this `.env` file.**
`myPlugin` has already been added to the `payload.config()` file in this project.
```ts
plugins: [
myPlugin({
collections: {
posts: true,
},
}),
]
```
Later when you rename the plugin or add additional options, **make sure to update it here**.
You may wish to add collections or expand the test project depending on the purpose of your plugin. Just make sure to keep this dev environment as simplified as possible - users should be able to install your plugin without additional configuration required.
When youre ready to start development, initiate the project with `pnpm/npm/yarn dev` and pull up [http://localhost:3000](http://localhost:3000) in your browser.
#### Src
Now that we have our environment setup and we have a dev project ready to - its time to build the plugin!
**index.ts**
The essence of a Payload plugin is simply to extend the payload config - and that is exactly what we are doing in this file.
```ts
export const myPlugin =
(pluginOptions: MyPluginConfig) =>
(config: Config): Config => {
// do cool stuff with the config here
return config
}
```
First, we receive the existing payload config along with any plugin options.
From here, you can extend the config as you wish.
Finally, you return the config and that is it!
##### Spread Syntax
Spread syntax (or the spread operator) is a feature in JavaScript that uses the dot notation **(...)** to spread elements from arrays, strings, or objects into various contexts.
We are going to use spread syntax to allow us to add data to existing arrays without losing the existing data. It is crucial to spread the existing data correctly else this can cause adverse behavior and conflicts with Payload config and other plugins.
Lets say you want to build a plugin that adds a new collection:
```ts
config.collections = [
...(config.collections || []),
// Add additional collections here
]
```
First we spread the `config.collections` to ensure that we dont lose the existing collections, then you can add any additional collections just as you would in a regular payload config.
This same logic is applied to other properties like admin, hooks, globals:
```ts
config.globals = [
...(config.globals || []),
// Add additional globals here
]
config.hooks = {
...(incomingConfig.hooks || {}),
// Add additional hooks here
}
```
Some properties will be slightly different to extend, for instance the onInit property:
```ts
import { onInitExtension } from './onInitExtension' // example file
config.onInit = async (payload) => {
if (incomingConfig.onInit) await incomingConfig.onInit(payload)
// Add additional onInit code by defining an onInitExtension function
onInitExtension(pluginOptions, payload)
}
```
If you wish to add to the onInit, you must include the **async/await**. We dont use spread syntax in this case, instead you must await the existing `onInit` before running additional functionality.
In the template, we have stubbed out some addition `onInit` actions that seeds in a document to the `plugin-collection`, you can use this as a base point to add more actions - and if not needed, feel free to delete it.
##### Types.ts
If your plugin has options, you should define and provide types for these options.
```ts
export type MyPluginConfig = {
/**
* List of collections to add a custom field
*/
collections?: Partial<Record<CollectionSlug, true>>
/**
* Disable the plugin
*/
disabled?: boolean
}
```
If possible, include JSDoc comments to describe the options and their types. This allows a developer to see details about the options in their editor.
##### Testing
Having a test suite for your plugin is essential to ensure quality and stability. **Vitest** is a fast, modern testing framework that works seamlessly with Vite and supports TypeScript out of the box.
Vitest organizes tests into test suites and cases, similar to other testing frameworks. We recommend creating individual tests based on the expected behavior of your plugin from start to finish.
Writing tests with Vitest is very straightforward, and you can learn more about how it works in the [Vitest documentation.](https://vitest.dev/)
For this template, we stubbed out `int.spec.ts` in the `dev` folder where you can write your tests.
```ts
describe('Plugin tests', () => {
// Create tests to ensure expected behavior from the plugin
it('some condition that must be met', () => {
// Write your test logic here
expect(...)
})
})
```
## Best practices
With this tutorial and the plugin template, you should have everything you need to start building your own plugin.
In addition to the setup, here are other best practices aim we follow:
- **Providing an enable / disable option:** For a better user experience, provide a way to disable the plugin without uninstalling it. This is especially important if your plugin adds additional webpack aliases, this will allow you to still let the webpack run to prevent errors.
- **Include tests in your GitHub CI workflow**: If youve configured tests for your package, integrate them into your workflow to run the tests each time you commit to the plugin repository. Learn more about [how to configure tests into your GitHub CI workflow.](https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-nodejs)
- **Publish your finished plugin to NPM**: The best way to share and allow others to use your plugin once it is complete is to publish an NPM package. This process is straightforward and well documented, find out more [creating and publishing a NPM package here.](https://docs.npmjs.com/creating-and-publishing-scoped-public-packages/).
- **Add payload-plugin topic tag**: Apply the tag **payload-plugin **to your GitHub repository. This will boost the visibility of your plugin and ensure it gets listed with [existing payload plugins](https://github.com/topics/payload-plugin).
- **Use [Semantic Versioning](https://semver.org/) (SemVar)** - With the SemVar system you release version numbers that reflect the nature of changes (major, minor, patch). Ensure all major versions reference their Payload compatibility.
# Questions
Please contact [Payload](mailto:dev@payloadcms.com) with any questions about using this plugin template.

View File

@@ -0,0 +1,15 @@
import { expect, test } from '@playwright/test'
// this is an example Playwright e2e test
test('should render admin panel logo', async ({ page }) => {
await page.goto('/admin')
// login
await page.fill('#field-email', 'dev@payloadcms.com')
await page.fill('#field-password', 'test')
await page.click('.form-submit button')
// should show dashboard
await expect(page).toHaveTitle(/Dashboard/)
await expect(page.locator('.graphic-icon')).toBeVisible()
})

View File

@@ -1,245 +0,0 @@
import type { JoinQuery, PopulateType, SanitizedConfig, SelectType, Where } from 'payload'
import type { ParsedQs } from 'qs-esm'
import {
REST_DELETE as createDELETE,
REST_GET as createGET,
GRAPHQL_POST as createGraphqlPOST,
REST_PATCH as createPATCH,
REST_POST as createPOST,
REST_PUT as createPUT,
} from '@payloadcms/next/routes'
import * as qs from 'qs-esm'
import { devUser } from './credentials.js'
type ValidPath = `/${string}`
type RequestOptions = {
auth?: boolean
query?: {
depth?: number
fallbackLocale?: string
joins?: JoinQuery
limit?: number
locale?: string
page?: number
populate?: PopulateType
select?: SelectType
sort?: string
where?: Where
}
}
type FileArg = {
file?: Omit<File, 'webkitRelativePath'>
}
function generateQueryString(query: RequestOptions['query'], params?: ParsedQs): string {
return qs.stringify(
{
...(params || {}),
...(query || {}),
},
{
addQueryPrefix: true,
},
)
}
export class NextRESTClient {
private _DELETE: (
request: Request,
args: { params: Promise<{ slug: string[] }> },
) => Promise<Response>
private _GET: (
request: Request,
args: { params: Promise<{ slug: string[] }> },
) => Promise<Response>
private _GRAPHQL_POST: (request: Request) => Promise<Response>
private _PATCH: (
request: Request,
args: { params: Promise<{ slug: string[] }> },
) => Promise<Response>
private _POST: (
request: Request,
args: { params: Promise<{ slug: string[] }> },
) => Promise<Response>
private _PUT: (
request: Request,
args: { params: Promise<{ slug: string[] }> },
) => Promise<Response>
private readonly config: SanitizedConfig
private token?: string
serverURL: string = 'http://localhost:3000'
constructor(config: SanitizedConfig) {
this.config = config
if (config?.serverURL) {
this.serverURL = config.serverURL
}
this._GET = createGET(config)
this._POST = createPOST(config)
this._DELETE = createDELETE(config)
this._PATCH = createPATCH(config)
this._PUT = createPUT(config)
this._GRAPHQL_POST = createGraphqlPOST(config)
}
private buildHeaders(options: FileArg & RequestInit & RequestOptions): Headers {
const defaultHeaders = {
'Content-Type': 'application/json',
}
const headers = new Headers({
...(options?.file
? {
'Content-Length': options.file.size.toString(),
}
: defaultHeaders),
...(options?.headers || {}),
})
if (options.auth !== false && this.token) {
headers.set('Authorization', `JWT ${this.token}`)
}
if (options.auth === false) {
headers.set('DisableAutologin', 'true')
}
return headers
}
private generateRequestParts(path: ValidPath): {
params?: ParsedQs
slug: string[]
url: string
} {
const [slugs, params] = path.slice(1).split('?')
const url = `${this.serverURL}${this.config.routes.api}/${slugs}`
return {
slug: slugs.split('/'),
params: params ? qs.parse(params) : undefined,
url,
}
}
async DELETE(path: ValidPath, options: RequestInit & RequestOptions = {}): Promise<Response> {
const { slug, params, url } = this.generateRequestParts(path)
const { query, ...rest } = options || {}
const queryParams = generateQueryString(query, params)
const request = new Request(`${url}${queryParams}`, {
...rest,
headers: this.buildHeaders(options),
method: 'DELETE',
})
return this._DELETE(request, { params: Promise.resolve({ slug }) })
}
async GET(
path: ValidPath,
options: Omit<RequestInit, 'body'> & RequestOptions = {},
): Promise<Response> {
const { slug, params, url } = this.generateRequestParts(path)
const { query, ...rest } = options || {}
const queryParams = generateQueryString(query, params)
const request = new Request(`${url}${queryParams}`, {
...rest,
headers: this.buildHeaders(options),
method: 'GET',
})
return this._GET(request, { params: Promise.resolve({ slug }) })
}
async GRAPHQL_POST(options: RequestInit & RequestOptions): Promise<Response> {
const { query, ...rest } = options
const queryParams = generateQueryString(query, {})
const request = new Request(
`${this.serverURL}${this.config.routes.api}${this.config.routes.graphQL}${queryParams}`,
{
...rest,
headers: this.buildHeaders(options),
method: 'POST',
},
)
return this._GRAPHQL_POST(request)
}
async login({
slug,
credentials,
}: {
credentials?: {
email: string
password: string
}
slug: string
}): Promise<{ [key: string]: unknown }> {
const response = await this.POST(`/${slug}/login`, {
body: JSON.stringify(
credentials ? { ...credentials } : { email: devUser.email, password: devUser.password },
),
})
const result = await response.json()
this.token = result.token
if (!result.token) {
// If the token is not in the response body, then we can extract it from the cookies
const setCookie = response.headers.get('Set-Cookie')
const tokenMatchResult = setCookie?.match(/payload-token=(?<token>.+?);/)
this.token = tokenMatchResult?.groups?.token
}
return result
}
async PATCH(path: ValidPath, options: FileArg & RequestInit & RequestOptions): Promise<Response> {
const { slug, params, url } = this.generateRequestParts(path)
const { query, ...rest } = options
const queryParams = generateQueryString(query, params)
const request = new Request(`${url}${queryParams}`, {
...rest,
headers: this.buildHeaders(options),
method: 'PATCH',
})
return this._PATCH(request, { params: Promise.resolve({ slug }) })
}
async POST(
path: ValidPath,
options: FileArg & RequestInit & RequestOptions = {},
): Promise<Response> {
const { slug, params, url } = this.generateRequestParts(path)
const queryParams = generateQueryString({}, params)
const request = new Request(`${url}${queryParams}`, {
...options,
headers: this.buildHeaders(options),
method: 'POST',
})
return this._POST(request, { params: Promise.resolve({ slug }) })
}
async PUT(path: ValidPath, options: FileArg & RequestInit & RequestOptions): Promise<Response> {
const { slug, params, url } = this.generateRequestParts(path)
const { query, ...rest } = options
const queryParams = generateQueryString(query, params)
const request = new Request(`${url}${queryParams}`, {
...rest,
headers: this.buildHeaders(options),
method: 'PUT',
})
return this._PUT(request, { params: Promise.resolve({ slug }) })
}
}

View File

@@ -1,66 +1,31 @@
/* eslint-disable no-console */
/**
* Here are your integration tests for the plugin.
* They don't require running your Next.js so they are fast
* Yet they still can test the Local API and custom endpoints using NextRESTClient helper.
*/
import type { Payload } from 'payload'
import dotenv from 'dotenv'
import { MongoMemoryReplSet } from 'mongodb-memory-server'
import path from 'path'
import { getPayload } from 'payload'
import { fileURLToPath } from 'url'
import config from '@payload-config'
import { createPayloadRequest, getPayload } from 'payload'
import { afterAll, beforeAll, describe, expect, test } from 'vitest'
import { NextRESTClient } from './helpers/NextRESTClient.js'
const dirname = path.dirname(fileURLToPath(import.meta.url))
import { customEndpointHandler } from '../src/endpoints/customEndpointHandler.js'
let payload: Payload
let restClient: NextRESTClient
let memoryDB: MongoMemoryReplSet | undefined
describe('Plugin tests', () => {
beforeAll(async () => {
process.env.DISABLE_PAYLOAD_HMR = 'true'
process.env.PAYLOAD_DROP_DATABASE = 'true'
afterAll(async () => {
if (payload.db.destroy) {
await payload.db.destroy()
}
})
dotenv.config({
path: path.resolve(dirname, './.env'),
beforeAll(async () => {
payload = await getPayload({ config })
})
describe('Plugin integration tests', () => {
test('should query custom endpoint added by plugin', async () => {
const request = new Request('http://localhost:3000/api/my-plugin-endpoint', {
method: 'GET',
})
if (!process.env.DATABASE_URI) {
console.log('Starting memory database')
memoryDB = await MongoMemoryReplSet.create({
replSet: {
count: 3,
dbName: 'payloadmemory',
},
})
console.log('Memory database started')
process.env.DATABASE_URI = `${memoryDB.getUri()}&retryWrites=true`
}
const { default: config } = await import('./payload.config.js')
payload = await getPayload({ config })
restClient = new NextRESTClient(payload.config)
})
afterAll(async () => {
if (payload.db.destroy) {
await payload.db.destroy()
}
if (memoryDB) {
await memoryDB.stop()
}
})
it('should query added by plugin custom endpoint', async () => {
const response = await restClient.GET('/my-plugin-endpoint')
const payloadRequest = await createPayloadRequest({ config, request })
const response = await customEndpointHandler(payloadRequest)
expect(response.status).toBe(200)
const data = await response.json()
@@ -69,18 +34,17 @@ describe('Plugin tests', () => {
})
})
it('can create post with a custom text field added by plugin', async () => {
test('can create post with custom text field added by plugin', async () => {
const post = await payload.create({
collection: 'posts',
data: {
addedByPlugin: 'added by plugin',
},
})
expect(post.addedByPlugin).toBe('added by plugin')
})
it('plugin creates and seeds plugin-collection', async () => {
test('plugin creates and seeds plugin-collection', async () => {
expect(payload.collections['plugin-collection']).toBeDefined()
const { docs } = await payload.find({ collection: 'plugin-collection' })

View File

@@ -15,7 +15,7 @@ const nextConfig = {
return webpackConfig
},
// transpilePackages: ['../src'],
serverExternalPackages: ['mongodb-memory-server'],
}
export default withPayload(nextConfig, { devBundleServerPackages: false })

View File

@@ -1,12 +1,12 @@
import { mongooseAdapter } from '@payloadcms/db-mongodb'
import { lexicalEditor } from '@payloadcms/richtext-lexical'
import { MongoMemoryReplSet } from 'mongodb-memory-server'
import path from 'path'
import { buildConfig } from 'payload'
import { myPlugin } from 'plugin-package-name-placeholder'
import sharp from 'sharp'
import { fileURLToPath } from 'url'
import { devUser } from './helpers/credentials.js'
import { testEmailAdapter } from './helpers/testEmailAdapter.js'
import { seed } from './seed.js'
@@ -17,44 +17,59 @@ if (!process.env.ROOT_DIR) {
process.env.ROOT_DIR = dirname
}
export default buildConfig({
admin: {
autoLogin: devUser,
importMap: {
baseDir: path.resolve(dirname),
},
},
collections: [
{
slug: 'posts',
fields: [],
},
{
slug: 'media',
fields: [],
upload: {
staticDir: path.resolve(dirname, 'media'),
const buildConfigWithMemoryDB = async () => {
if (process.env.NODE_ENV === 'test') {
const memoryDB = await MongoMemoryReplSet.create({
replSet: {
count: 3,
dbName: 'payloadmemory',
},
})
process.env.DATABASE_URI = `${memoryDB.getUri()}&retryWrites=true`
}
return buildConfig({
admin: {
importMap: {
baseDir: path.resolve(dirname),
},
},
],
db: mongooseAdapter({
url: process.env.DATABASE_URI || '',
}),
editor: lexicalEditor(),
email: testEmailAdapter,
onInit: async (payload) => {
await seed(payload)
},
plugins: [
myPlugin({
collections: {
posts: true,
collections: [
{
slug: 'posts',
fields: [],
},
{
slug: 'media',
fields: [],
upload: {
staticDir: path.resolve(dirname, 'media'),
},
},
],
db: mongooseAdapter({
ensureIndexes: true,
url: process.env.DATABASE_URI || '',
}),
],
secret: process.env.PAYLOAD_SECRET || 'test-secret_key',
sharp,
typescript: {
outputFile: path.resolve(dirname, 'payload-types.ts'),
},
})
editor: lexicalEditor(),
email: testEmailAdapter,
onInit: async (payload) => {
await seed(payload)
},
plugins: [
myPlugin({
collections: {
posts: true,
},
}),
],
secret: process.env.PAYLOAD_SECRET || 'test-secret_key',
sharp,
typescript: {
outputFile: path.resolve(dirname, 'payload-types.ts'),
},
})
}
export default buildConfigWithMemoryDB()

View File

@@ -1,29 +0,0 @@
import type { NextServerOptions } from 'next/dist/server/next.js'
import { createServer } from 'http'
import next from 'next'
import open from 'open'
import path from 'path'
import { fileURLToPath, parse } from 'url'
const dirname = path.dirname(fileURLToPath(import.meta.url))
const opts: NextServerOptions = {
dev: true,
dir: dirname,
}
// @ts-expect-error next types do not import
const app = next(opts)
const handle = app.getRequestHandler()
await app.prepare()
await open(`http://localhost:3000/admin`)
const server = createServer((req, res) => {
const parsedUrl = parse(req.url!, true)
void handle(req, res, parsedUrl)
})
server.listen(3000)

View File

@@ -10,7 +10,7 @@ export const defaultESLintIgnores = [
'**/.pnp.*',
'**/.svn',
'**/playwright.config.ts',
'**/jest.config.js',
'**/vitest.config.js',
'**/tsconfig.tsbuildinfo',
'**/README.md',
'**/eslint.config.js',

View File

@@ -1,53 +0,0 @@
const esModules = [
// file-type and all dependencies: https://github.com/sindresorhus/file-type
'file-type',
'strtok3',
'readable-web-to-node-stream',
'token-types',
'peek-readable',
'locate-path',
'p-locate',
'p-limit',
'yocto-queue',
'unicorn-magic',
'path-exists',
'qs-esm',
'uint8array-extras',
'payload',
'@payloadcms/next',
'@payloadcms/ui',
'@payloadcms/graphql',
'@payloadcms/translations',
'@payloadcms/db-mongodb',
'@payloadcms/richtext-lexical',
].join('|')
/** @type {import('jest').Config} */
const customJestConfig = {
extensionsToTreatAsEsm: ['.ts', '.tsx'],
transformIgnorePatterns: [
`/node_modules/(?!.pnpm)(?!(${esModules})/)`,
`/node_modules/.pnpm/(?!(${esModules.replace(/\//g, '\\+')})@)`,
],
moduleNameMapper: {
'\\.(css|scss)$': '<rootDir>/test/helpers/mocks/emptyModule.js',
'\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$':
'<rootDir>/test/helpers/mocks/fileMock.js',
'^(\\.{1,2}/.*)\\.js$': '$1',
},
testEnvironment: 'node',
testTimeout: 90000,
transform: {
'^.+\\.(t|j)sx?$': ['@swc/jest'],
},
verbose: true,
testMatch: ['<rootDir>/**/*int.spec.ts'],
moduleNameMapper: {
'\\.(css|scss)$': '<rootDir>/helpers/mocks/emptyModule.js',
'\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$':
'<rootDir>/helpers/mocks/fileMock.js',
'^(\\.{1,2}/.*)\\.js$': '$1',
},
}
export default customJestConfig

View File

@@ -6,9 +6,9 @@
"type": "module",
"exports": {
".": {
"import": "./src/index.ts",
"types": "./src/index.ts",
"default": "./src/index.ts"
"import": "./src/exports/index.ts",
"types": "./src/exports/index.ts",
"default": "./src/exports/index.ts"
},
"./client": {
"import": "./src/exports/client.ts",
@@ -21,8 +21,8 @@
"default": "./src/exports/rsc.ts"
}
},
"main": "./src/index.ts",
"types": "./src/index.ts",
"main": "./src/exports/index.ts",
"types": "./src/exports/index.ts",
"files": [
"dist"
],
@@ -32,28 +32,29 @@
"build:types": "tsc --outDir dist --rootDir ./src",
"clean": "rimraf {dist,*.tsbuildinfo}",
"copyfiles": "copyfiles -u 1 \"src/**/*.{html,css,scss,ttf,woff,woff2,eot,svg,jpg,png,json}\" dist/",
"dev": "payload run ./dev/server.ts",
"dev": "next dev dev --turbo",
"dev:generate-importmap": "pnpm dev:payload generate:importmap",
"dev:generate-types": "pnpm dev:payload generate:types",
"dev:payload": "cross-env PAYLOAD_CONFIG_PATH=./dev/payload.config.ts payload",
"lint": "eslint",
"lint:fix": "eslint ./src --fix",
"prepublishOnly": "pnpm clean && pnpm build",
"test": "jest"
"test": "pnpm test:int && pnpm test:e2e",
"test:e2e": "playwright test",
"test:int": "vitest"
},
"devDependencies": {
"@eslint/eslintrc": "^3.2.0",
"@payloadcms/db-mongodb": "3.29.0",
"@payloadcms/db-postgres": "3.29.0",
"@payloadcms/db-sqlite": "3.29.0",
"@payloadcms/db-mongodb": "3.37.0",
"@payloadcms/db-postgres": "3.37.0",
"@payloadcms/db-sqlite": "3.37.0",
"@payloadcms/eslint-config": "3.9.0",
"@payloadcms/next": "3.29.0",
"@payloadcms/richtext-lexical": "3.29.0",
"@payloadcms/ui": "3.29.0",
"@payloadcms/next": "3.37.0",
"@payloadcms/richtext-lexical": "3.37.0",
"@payloadcms/ui": "3.37.0",
"@playwright/test": "^1.52.0",
"@swc-node/register": "1.10.9",
"@swc/cli": "0.6.0",
"@swc/jest": "^0.2.37",
"@types/jest": "29.5.12",
"@types/node": "^22.5.4",
"@types/react": "19.1.0",
"@types/react-dom": "19.1.2",
@@ -62,11 +63,10 @@
"eslint": "^9.23.0",
"eslint-config-next": "15.3.0",
"graphql": "^16.8.1",
"jest": "29.7.0",
"mongodb-memory-server": "^10.1.2",
"next": "15.3.0",
"open": "^10.1.0",
"payload": "3.29.0",
"payload": "3.37.0",
"prettier": "^3.4.2",
"qs-esm": "7.0.2",
"react": "19.1.0",
@@ -74,10 +74,12 @@
"rimraf": "3.0.2",
"sharp": "0.32.6",
"sort-package-json": "^2.10.0",
"typescript": "5.7.3"
"typescript": "5.7.3",
"vite-tsconfig-paths": "^5.1.4",
"vitest": "^3.1.2"
},
"peerDependencies": {
"payload": "^3.29.0"
"payload": "^3.37.0"
},
"engines": {
"node": "^18.20.2 || >=20.9.0",
@@ -86,9 +88,9 @@
"publishConfig": {
"exports": {
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
"import": "./dist/exports/index.js",
"types": "./dist/exports/index.d.ts",
"default": "./dist/exports/index.js"
},
"./client": {
"import": "./dist/exports/client.js",
@@ -101,8 +103,8 @@
"default": "./dist/exports/rsc.js"
}
},
"main": "./dist/index.js",
"types": "./dist/index.d.ts"
"main": "./dist/exports/index.js",
"types": "./dist/exports/index.d.ts"
},
"pnpm": {
"onlyBuiltDependencies": [

View File

@@ -0,0 +1,46 @@
import { defineConfig, devices } from '@playwright/test'
/**
* Read environment variables from file.
* https://github.com/motdotla/dotenv
*/
// import dotenv from 'dotenv';
// import path from 'path';
// dotenv.config({ path: path.resolve(__dirname, '.env') });
/**
* See https://playwright.dev/docs/test-configuration.
*/
export default defineConfig({
testDir: './dev',
testMatch: '**/e2e.spec.{ts,js}',
/* Run tests in files in parallel */
fullyParallel: true,
/* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: !!process.env.CI,
/* Retry on CI only */
retries: process.env.CI ? 2 : 0,
/* Opt out of parallel tests on CI. */
workers: process.env.CI ? 1 : undefined,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: 'html',
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
use: {
/* Base URL to use in actions like `await page.goto('/')`. */
baseURL: 'http://localhost:3000',
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: 'on-first-retry',
},
webServer: {
command: 'pnpm dev',
reuseExistingServer: true,
url: 'http://localhost:3000/admin',
},
})

View File

@@ -0,0 +1,5 @@
import type { PayloadHandler } from 'payload'
export const customEndpointHandler: PayloadHandler = () => {
return Response.json({ message: 'Hello from custom endpoint' })
}

View File

@@ -1,5 +1,7 @@
import type { CollectionSlug, Config } from 'payload'
import { customEndpointHandler } from './endpoints/customEndpointHandler.js'
export type MyPluginConfig = {
/**
* List of collections to add a custom field
@@ -75,9 +77,7 @@ export const myPlugin =
)
config.endpoints.push({
handler: () => {
return Response.json({ message: 'Hello from custom endpoint' })
},
handler: customEndpointHandler,
method: 'get',
path: '/my-plugin-endpoint',
})

View File

@@ -29,6 +29,6 @@
"include": [
"./src/**/*.ts",
"./src/**/*.tsx",
"./dev/next-env.d.ts"
"./dev/next-env.d.ts",
],
}
}

View File

@@ -0,0 +1,25 @@
import path from 'path'
import { loadEnv } from 'payload/node'
import { fileURLToPath } from 'url'
import tsconfigPaths from 'vite-tsconfig-paths'
import { defineConfig } from 'vitest/config'
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
export default defineConfig(() => {
loadEnv(path.resolve(dirname, './dev'))
return {
plugins: [
tsconfigPaths({
ignoreConfigErrors: true,
}),
],
test: {
environment: 'node',
hookTimeout: 30_000,
testTimeout: 30_000,
},
}
})