Merge remote-tracking branch 'plugin-seo/main' into chore/plugin-seo
This commit is contained in:
10
packages/plugin-seo/.editorconfig
Normal file
10
packages/plugin-seo/.editorconfig
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 2
|
||||||
|
charset = utf-8
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
insert_final_newline = true
|
||||||
|
end_of_line = lf
|
||||||
|
max_line_length = null
|
||||||
3
packages/plugin-seo/.eslintrc.js
Normal file
3
packages/plugin-seo/.eslintrc.js
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
module.exports = {
|
||||||
|
extends: ['@payloadcms'],
|
||||||
|
}
|
||||||
7
packages/plugin-seo/.gitignore
vendored
Normal file
7
packages/plugin-seo/.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
node_modules
|
||||||
|
.env
|
||||||
|
dist
|
||||||
|
demo/uploads
|
||||||
|
build
|
||||||
|
.DS_Store
|
||||||
|
package-lock.json
|
||||||
8
packages/plugin-seo/.prettierrc.js
Normal file
8
packages/plugin-seo/.prettierrc.js
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
module.exports = {
|
||||||
|
printWidth: 100,
|
||||||
|
parser: "typescript",
|
||||||
|
semi: false,
|
||||||
|
singleQuote: true,
|
||||||
|
trailingComma: "all",
|
||||||
|
arrowParens: "avoid",
|
||||||
|
};
|
||||||
206
packages/plugin-seo/README.md
Normal file
206
packages/plugin-seo/README.md
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
# Payload SEO Plugin
|
||||||
|
|
||||||
|
[](https://www.npmjs.com/package/@payloadcms/plugin-seo)
|
||||||
|
|
||||||
|
A plugin for [Payload](https://github.com/payloadcms/payload) to auto-generate SEO meta data based on the content of your documents.
|
||||||
|
|
||||||
|
Core features:
|
||||||
|
|
||||||
|
- Adds a `meta` field group to every SEO-enabled collection or global
|
||||||
|
- Auto-generates meta data using your document's existing content
|
||||||
|
- Displays hints and indicators to help content editor write effective meta
|
||||||
|
- Renders a snippet of what a search engine might display
|
||||||
|
- Extendable so you can define custom fields like `og:title` or `json-ld`
|
||||||
|
- Soon will dynamic support variable injection
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
yarn add @payloadcms/plugin-seo
|
||||||
|
# OR
|
||||||
|
npm i @payloadcms/plugin-seo
|
||||||
|
```
|
||||||
|
|
||||||
|
## Basic Usage
|
||||||
|
|
||||||
|
In the `plugins` array of your [Payload config](https://payloadcms.com/docs/configuration/overview), call the plugin with [options](#options):
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { buildConfig } from 'payload/config';
|
||||||
|
import seo from '@payloadcms/plugin-seo';
|
||||||
|
|
||||||
|
const config = buildConfig({
|
||||||
|
collections: [
|
||||||
|
{
|
||||||
|
slug: 'pages',
|
||||||
|
fields: []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: 'media',
|
||||||
|
upload: {
|
||||||
|
staticDir: // path to your static directory,
|
||||||
|
},
|
||||||
|
fields: []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
plugins: [
|
||||||
|
seo({
|
||||||
|
collections: [
|
||||||
|
'pages',
|
||||||
|
],
|
||||||
|
uploadsCollection: 'media',
|
||||||
|
generateTitle: ({ doc }) => `Website.com — ${doc.title.value}`,
|
||||||
|
generateDescription: ({ doc }) => doc.excerpt
|
||||||
|
})
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
export default config;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Options
|
||||||
|
|
||||||
|
- `collections` : string[] | optional
|
||||||
|
|
||||||
|
An array of collections slugs to enable SEO. Enabled collections receive a `meta` field which is an object of title, description, and image subfields.
|
||||||
|
|
||||||
|
- `globals` : string[] | optional
|
||||||
|
|
||||||
|
An array of global slugs to enable SEO. Enabled globals receive a `meta` field which is an object of title, description, and image subfields.
|
||||||
|
|
||||||
|
- `fields` Field[] | optional
|
||||||
|
|
||||||
|
Append your own custom fields onto the `meta` field group. The following fields are provided by default:
|
||||||
|
|
||||||
|
- `title`: text
|
||||||
|
- `description`: textarea
|
||||||
|
- `image`: upload (if an `uploadsCollection` is provided)
|
||||||
|
- `preview`: ui
|
||||||
|
|
||||||
|
- `uploadsCollection` : string | optional
|
||||||
|
|
||||||
|
An upload-enabled collection slug, for the meta image to access.
|
||||||
|
|
||||||
|
- `tabbedUI` : boolean | optional
|
||||||
|
|
||||||
|
Appends an `SEO` tab onto your config using Payload's [Tabs Field](https://payloadcms.com/docs/fields/tabs). If your collection is not already tab-enabled, meaning the first field in your config is not of type `tabs`, then one will be created for you called `Content`. Defaults to `false`.
|
||||||
|
|
||||||
|
> If you wish to continue to use top-level or sidebar fields with `tabbedUI`, you must not let the default `Content` tab get created for you (see the note above). Instead, you must define the first field of your config with type `tabs` and place all other fields adjacent to this one.
|
||||||
|
|
||||||
|
- `generateTitle` : method | optional
|
||||||
|
|
||||||
|
A function that allows you to return any meta title, including from document's content.
|
||||||
|
|
||||||
|
```js
|
||||||
|
seo({
|
||||||
|
...
|
||||||
|
generateTitle: ({ ...docInfo, doc, locale }) => `Website.com — ${doc?.title?.value}`,
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
- `generateDescription` : method | optional
|
||||||
|
|
||||||
|
A function that allows you to return any meta description, including from document's content.
|
||||||
|
|
||||||
|
```js
|
||||||
|
seo({
|
||||||
|
...
|
||||||
|
generateDescription: ({ ...docInfo, doc, locale }) => doc?.excerpt?.value
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
- `generateImage` : method | optional
|
||||||
|
|
||||||
|
A function that allows you to return any meta image, including from document's content.
|
||||||
|
|
||||||
|
```js
|
||||||
|
seo({
|
||||||
|
...
|
||||||
|
generateImage: ({ ...docInfo, doc, locale }) => doc?.featuredImage?.value
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
- `generateURL` : method | optional
|
||||||
|
|
||||||
|
A function called by the search preview component to display the actual URL of your page.
|
||||||
|
|
||||||
|
```js
|
||||||
|
seo({
|
||||||
|
...
|
||||||
|
generateURL: ({ ...docInfo, doc, locale }) => `https://yoursite.com/${collection?.slug}/${doc?.slug?.value}`
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## TypeScript
|
||||||
|
|
||||||
|
All types can be directly imported:
|
||||||
|
|
||||||
|
```js
|
||||||
|
import {
|
||||||
|
PluginConfig,
|
||||||
|
GenerateTitle,
|
||||||
|
GenerateDescription
|
||||||
|
GenerateURL
|
||||||
|
} from '@payloadcms/plugin-seo/types';
|
||||||
|
```
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
To actively develop or debug this plugin you can either work directly within the demo directory of this repo, or link your own project.
|
||||||
|
|
||||||
|
1. #### Internal Demo
|
||||||
|
|
||||||
|
This repo includes a fully working, self-seeding instance of Payload that installs the plugin directly from the source code. This is the easiest way to get started. To spin up this demo, follow these steps:
|
||||||
|
|
||||||
|
1. First clone the repo
|
||||||
|
1. Then, `cd YOUR_PLUGIN_REPO && yarn && cd demo && yarn && yarn dev`
|
||||||
|
1. Now open `http://localhost:3000/admin` in your browser
|
||||||
|
1. Enter username `dev@payloadcms.com` and password `test`
|
||||||
|
|
||||||
|
That's it! Changes made in `./src` will be reflected in your demo. Keep in mind that the demo database is automatically seeded on every startup, any changes you make to the data get destroyed each time you reboot the app.
|
||||||
|
|
||||||
|
1. #### Linked Project
|
||||||
|
|
||||||
|
You can alternatively link your own project to the source code:
|
||||||
|
|
||||||
|
1. First clone the repo
|
||||||
|
1. Then, `cd YOUR_PLUGIN_REPO && yarn && cd demo && cp env.example .env && yarn && yarn dev`
|
||||||
|
1. Now `cd` back into your own project and run, `yarn link @payloadcms/plugin-seo`
|
||||||
|
1. If this plugin using React in any way, continue to the next step. Otherwise skip to step 7.
|
||||||
|
1. From your own project, `cd node_modules/react && yarn link && cd ../react-dom && yarn link && cd ../../`
|
||||||
|
1. Then, `cd YOUR_PLUGIN_REPO && yarn link react react-dom`
|
||||||
|
|
||||||
|
All set! You can now boot up your own project as normal, and your local copy of the plugin source code will be used. Keep in mind that changes to the source code require a rebuild, `yarn build`.
|
||||||
|
|
||||||
|
You might also need to alias these modules in your Webpack config. To do this, open your project's Payload config and add the following:
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { buildConfig } from "payload/config";
|
||||||
|
|
||||||
|
export default buildConfig({
|
||||||
|
admin: {
|
||||||
|
webpack: (config) => ({
|
||||||
|
...config,
|
||||||
|
resolve: {
|
||||||
|
...config.resolve,
|
||||||
|
alias: {
|
||||||
|
...config.resolve.alias,
|
||||||
|
react: path.join(__dirname, "../node_modules/react"),
|
||||||
|
"react-dom": path.join(__dirname, "../node_modules/react-dom"),
|
||||||
|
payload: path.join(__dirname, "../node_modules/payload"),
|
||||||
|
"@payloadcms/plugin-seo": path.join(
|
||||||
|
__dirname,
|
||||||
|
"../../payload/plugin-seo/src"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Screenshots
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
<!--  -->
|
||||||
2
packages/plugin-seo/demo/.env.example
Normal file
2
packages/plugin-seo/demo/.env.example
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
MONGODB_URI=mongodb://localhost/payload-plugin-seo
|
||||||
|
PAYLOAD_SECRET=kjnsaldkjfnasdljkfghbnseanljnuadlrigjandrg
|
||||||
4
packages/plugin-seo/demo/nodemon.json
Normal file
4
packages/plugin-seo/demo/nodemon.json
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"ext": "ts",
|
||||||
|
"exec": "ts-node src/server.ts"
|
||||||
|
}
|
||||||
27
packages/plugin-seo/demo/package.json
Normal file
27
packages/plugin-seo/demo/package.json
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"name": "payload-starter-typescript",
|
||||||
|
"description": "Blank template - no collections",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"main": "dist/server.js",
|
||||||
|
"license": "MIT",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "cross-env PAYLOAD_SEED=true PAYLOAD_DROP_DATABASE=true PAYLOAD_CONFIG_PATH=src/payload.config.ts nodemon",
|
||||||
|
"build:payload": "cross-env PAYLOAD_CONFIG_PATH=src/payload.config.ts payload build",
|
||||||
|
"build:server": "tsc",
|
||||||
|
"build": "yarn build:payload && yarn build:server",
|
||||||
|
"serve": "cross-env PAYLOAD_CONFIG_PATH=dist/payload.config.js NODE_ENV=production node dist/server.js",
|
||||||
|
"generate:types": "cross-env PAYLOAD_CONFIG_PATH=src/payload.config.ts payload generate:types"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"dotenv": "^8.2.0",
|
||||||
|
"express": "^4.17.1",
|
||||||
|
"payload": "^1.8.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/express": "^4.17.9",
|
||||||
|
"cross-env": "^7.0.3",
|
||||||
|
"nodemon": "^2.0.6",
|
||||||
|
"ts-node": "^9.1.1",
|
||||||
|
"typescript": "^4.1.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
25
packages/plugin-seo/demo/src/collections/Media.ts
Normal file
25
packages/plugin-seo/demo/src/collections/Media.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import path from 'path'
|
||||||
|
import type { CollectionConfig } from 'payload/types'
|
||||||
|
|
||||||
|
const Media: CollectionConfig = {
|
||||||
|
slug: 'media',
|
||||||
|
access: {
|
||||||
|
read: (): boolean => true,
|
||||||
|
},
|
||||||
|
admin: {
|
||||||
|
useAsTitle: 'filename',
|
||||||
|
},
|
||||||
|
upload: {
|
||||||
|
staticDir: path.resolve(__dirname, '../../uploads'),
|
||||||
|
},
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: 'alt',
|
||||||
|
label: 'Alt Text',
|
||||||
|
type: 'text',
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Media
|
||||||
33
packages/plugin-seo/demo/src/collections/Pages.ts
Normal file
33
packages/plugin-seo/demo/src/collections/Pages.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import type { CollectionConfig } from 'payload/types'
|
||||||
|
|
||||||
|
const Pages: CollectionConfig = {
|
||||||
|
slug: 'pages',
|
||||||
|
admin: {
|
||||||
|
useAsTitle: 'title',
|
||||||
|
},
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: 'title',
|
||||||
|
type: 'text',
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'excerpt',
|
||||||
|
type: 'text',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'slug',
|
||||||
|
type: 'text',
|
||||||
|
required: true,
|
||||||
|
// NOTE: in order for position: 'sidebar' to work here,
|
||||||
|
// the first field of this config must be of type `tabs`,
|
||||||
|
// and this field must be a sibling of it
|
||||||
|
// See `./Posts` or the `../../README.md` for more info
|
||||||
|
admin: {
|
||||||
|
position: 'sidebar',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Pages
|
||||||
41
packages/plugin-seo/demo/src/collections/Posts.ts
Normal file
41
packages/plugin-seo/demo/src/collections/Posts.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import type { CollectionConfig } from 'payload/types'
|
||||||
|
|
||||||
|
const Posts: CollectionConfig = {
|
||||||
|
slug: 'posts',
|
||||||
|
admin: {
|
||||||
|
useAsTitle: 'title',
|
||||||
|
},
|
||||||
|
versions: true,
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
type: 'tabs',
|
||||||
|
tabs: [
|
||||||
|
{
|
||||||
|
label: 'Content',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: 'title',
|
||||||
|
type: 'text',
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'excerpt',
|
||||||
|
type: 'text',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'slug',
|
||||||
|
label: 'Slug',
|
||||||
|
type: 'text',
|
||||||
|
required: true,
|
||||||
|
admin: {
|
||||||
|
position: 'sidebar',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Posts
|
||||||
18
packages/plugin-seo/demo/src/collections/Users.ts
Normal file
18
packages/plugin-seo/demo/src/collections/Users.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import type { CollectionConfig } from 'payload/types'
|
||||||
|
|
||||||
|
const Users: CollectionConfig = {
|
||||||
|
slug: 'users',
|
||||||
|
auth: true,
|
||||||
|
admin: {
|
||||||
|
useAsTitle: 'email',
|
||||||
|
},
|
||||||
|
access: {
|
||||||
|
read: () => true,
|
||||||
|
},
|
||||||
|
fields: [
|
||||||
|
// Email added by default
|
||||||
|
// Add more fields as needed
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Users
|
||||||
18
packages/plugin-seo/demo/src/globals/Settings.ts
Normal file
18
packages/plugin-seo/demo/src/globals/Settings.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import type { GlobalConfig } from 'payload/types'
|
||||||
|
|
||||||
|
const Settings: GlobalConfig = {
|
||||||
|
slug: 'settings',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: 'title',
|
||||||
|
type: 'text',
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'excerpt',
|
||||||
|
type: 'text',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Settings
|
||||||
62
packages/plugin-seo/demo/src/payload.config.ts
Normal file
62
packages/plugin-seo/demo/src/payload.config.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import path from 'path'
|
||||||
|
import { buildConfig } from 'payload/config'
|
||||||
|
|
||||||
|
// import seo from '../../dist';
|
||||||
|
import seo from '../../src'
|
||||||
|
import Media from './collections/Media'
|
||||||
|
import Pages from './collections/Pages'
|
||||||
|
import Posts from './collections/Posts'
|
||||||
|
import Users from './collections/Users'
|
||||||
|
import HomePage from './globals/Settings'
|
||||||
|
|
||||||
|
export default buildConfig({
|
||||||
|
serverURL: 'http://localhost:3000',
|
||||||
|
admin: {
|
||||||
|
user: Users.slug,
|
||||||
|
webpack: config => {
|
||||||
|
const newConfig = {
|
||||||
|
...config,
|
||||||
|
resolve: {
|
||||||
|
...config.resolve,
|
||||||
|
alias: {
|
||||||
|
...config.resolve.alias,
|
||||||
|
react: path.join(__dirname, '../node_modules/react'),
|
||||||
|
'react-dom': path.join(__dirname, '../node_modules/react-dom'),
|
||||||
|
payload: path.join(__dirname, '../node_modules/payload'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return newConfig
|
||||||
|
},
|
||||||
|
},
|
||||||
|
collections: [Users, Pages, Posts, Media],
|
||||||
|
globals: [HomePage],
|
||||||
|
localization: {
|
||||||
|
locales: ['en', 'es', 'de'],
|
||||||
|
defaultLocale: 'en',
|
||||||
|
fallback: true,
|
||||||
|
},
|
||||||
|
plugins: [
|
||||||
|
seo({
|
||||||
|
collections: ['pages', 'posts'],
|
||||||
|
globals: ['settings'],
|
||||||
|
tabbedUI: true,
|
||||||
|
uploadsCollection: 'media',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: 'ogTitle',
|
||||||
|
type: 'text',
|
||||||
|
label: 'og:title',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
generateTitle: (data: any) => `Website.com — ${data?.doc?.title?.value}`,
|
||||||
|
generateDescription: ({ doc }: any) => doc?.excerpt?.value,
|
||||||
|
generateURL: ({ doc, locale }: any) =>
|
||||||
|
`https://yoursite.com/${locale ? locale + '/' : ''}${doc?.slug?.value || ''}`,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
typescript: {
|
||||||
|
outputFile: path.resolve(__dirname, 'payload-types.ts'),
|
||||||
|
},
|
||||||
|
})
|
||||||
46
packages/plugin-seo/demo/src/seed/index.ts
Normal file
46
packages/plugin-seo/demo/src/seed/index.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import path from 'path'
|
||||||
|
import type { Payload } from 'payload'
|
||||||
|
|
||||||
|
export const seed = async (payload: Payload) => {
|
||||||
|
payload.logger.info('Seeding data...')
|
||||||
|
|
||||||
|
await payload.create({
|
||||||
|
collection: 'users',
|
||||||
|
data: {
|
||||||
|
email: 'dev@payloadcms.com',
|
||||||
|
password: 'test',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const { id: mountainPhotoID } = await payload.create({
|
||||||
|
collection: 'media',
|
||||||
|
filePath: path.resolve(__dirname, 'mountain-range.jpg'),
|
||||||
|
data: {
|
||||||
|
alt: 'Mountains',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await payload.create({
|
||||||
|
collection: 'pages',
|
||||||
|
data: {
|
||||||
|
title: 'Home Page',
|
||||||
|
slug: 'home',
|
||||||
|
excerpt: 'This is the home page',
|
||||||
|
meta: {
|
||||||
|
image: mountainPhotoID,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await payload.create({
|
||||||
|
collection: 'posts',
|
||||||
|
data: {
|
||||||
|
title: 'Hello, world!',
|
||||||
|
slug: 'hello-world',
|
||||||
|
excerpt: 'This is a post',
|
||||||
|
meta: {
|
||||||
|
image: mountainPhotoID,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
BIN
packages/plugin-seo/demo/src/seed/mountain-range.jpg
Normal file
BIN
packages/plugin-seo/demo/src/seed/mountain-range.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 60 KiB |
33
packages/plugin-seo/demo/src/server.ts
Normal file
33
packages/plugin-seo/demo/src/server.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import dotenv from 'dotenv'
|
||||||
|
import express from 'express'
|
||||||
|
import payload from 'payload'
|
||||||
|
|
||||||
|
import { seed } from './seed'
|
||||||
|
|
||||||
|
dotenv.config()
|
||||||
|
const app = express()
|
||||||
|
|
||||||
|
// Redirect root to Admin panel
|
||||||
|
app.get('/', (_, res) => {
|
||||||
|
res.redirect('/admin')
|
||||||
|
})
|
||||||
|
|
||||||
|
// Initialize Payload
|
||||||
|
const start = async (): Promise<void> => {
|
||||||
|
await payload.init({
|
||||||
|
secret: process.env.PAYLOAD_SECRET,
|
||||||
|
mongoURL: process.env.MONGODB_URI,
|
||||||
|
express: app,
|
||||||
|
onInit: () => {
|
||||||
|
payload.logger.info(`Payload Admin URL: ${payload.getAdminURL()}`)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (process.env.PAYLOAD_SEED === 'true') {
|
||||||
|
await seed(payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
app.listen(3000)
|
||||||
|
}
|
||||||
|
|
||||||
|
start()
|
||||||
19
packages/plugin-seo/demo/tsconfig.json
Normal file
19
packages/plugin-seo/demo/tsconfig.json
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "es5",
|
||||||
|
"lib": [
|
||||||
|
"dom",
|
||||||
|
"dom.iterable",
|
||||||
|
"esnext"
|
||||||
|
],
|
||||||
|
"strict": false,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "../",
|
||||||
|
"jsx": "react",
|
||||||
|
},
|
||||||
|
"ts-node": {
|
||||||
|
"transpileOnly": true
|
||||||
|
}
|
||||||
|
}
|
||||||
5902
packages/plugin-seo/demo/yarn.lock
Normal file
5902
packages/plugin-seo/demo/yarn.lock
Normal file
File diff suppressed because it is too large
Load Diff
61
packages/plugin-seo/package.json
Normal file
61
packages/plugin-seo/package.json
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
{
|
||||||
|
"name": "@payloadcms/plugin-seo",
|
||||||
|
"version": "1.0.15",
|
||||||
|
"homepage:": "https://payloadcms.com",
|
||||||
|
"repository": "git@github.com:payloadcms/plugin-seo.git",
|
||||||
|
"description": "SEO plugin for Payload",
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"types": "dist/index.d.ts",
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc",
|
||||||
|
"test": "echo \"Error: no test specified\" && exit 1",
|
||||||
|
"lint": "eslint src",
|
||||||
|
"lint:fix": "eslint --fix --ext .ts,.tsx src",
|
||||||
|
"clean": "rimraf dist",
|
||||||
|
"prepublishOnly": "yarn clean && yarn build"
|
||||||
|
},
|
||||||
|
"keywords": [
|
||||||
|
"payload",
|
||||||
|
"cms",
|
||||||
|
"plugin",
|
||||||
|
"typescript",
|
||||||
|
"react",
|
||||||
|
"seo",
|
||||||
|
"yoast"
|
||||||
|
],
|
||||||
|
"author": "dev@payloadcms.com",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"payload": "^0.18.5 || ^1.0.0 || ^2.0.0",
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@payloadcms/eslint-config": "^0.0.1",
|
||||||
|
"@types/express": "^4.17.9",
|
||||||
|
"@types/node": "18.11.3",
|
||||||
|
"@types/react": "18.0.21",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^5.51.0",
|
||||||
|
"@typescript-eslint/parser": "^5.51.0",
|
||||||
|
"copyfiles": "^2.4.1",
|
||||||
|
"cross-env": "^7.0.3",
|
||||||
|
"eslint": "^8.19.0",
|
||||||
|
"eslint-config-prettier": "^8.5.0",
|
||||||
|
"eslint-plugin-filenames": "^1.3.2",
|
||||||
|
"eslint-plugin-import": "2.25.4",
|
||||||
|
"eslint-plugin-prettier": "^4.0.0",
|
||||||
|
"eslint-plugin-react-hooks": "^4.6.0",
|
||||||
|
"eslint-plugin-simple-import-sort": "^10.0.0",
|
||||||
|
"nodemon": "^2.0.6",
|
||||||
|
"payload": "^1.8.2",
|
||||||
|
"prettier": "^2.7.1",
|
||||||
|
"react": "^18.0.0",
|
||||||
|
"rimraf": "^5.0.5",
|
||||||
|
"ts-node": "^9.1.1",
|
||||||
|
"typescript": "^4.8.4"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"dist",
|
||||||
|
"types.js",
|
||||||
|
"types.d.ts"
|
||||||
|
]
|
||||||
|
}
|
||||||
10
packages/plugin-seo/src/defaults.ts
Normal file
10
packages/plugin-seo/src/defaults.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
export const defaults = {
|
||||||
|
title: {
|
||||||
|
minLength: 50,
|
||||||
|
maxLength: 60,
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
minLength: 100,
|
||||||
|
maxLength: 150,
|
||||||
|
},
|
||||||
|
}
|
||||||
135
packages/plugin-seo/src/fields/MetaDescription.tsx
Normal file
135
packages/plugin-seo/src/fields/MetaDescription.tsx
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import React, { useCallback } from 'react'
|
||||||
|
import { useAllFormFields, useField } from 'payload/components/forms'
|
||||||
|
import { useDocumentInfo, useLocale } from 'payload/components/utilities'
|
||||||
|
import TextareaInput from 'payload/dist/admin/components/forms/field-types/Textarea/Input'
|
||||||
|
import { FieldType, Options } from 'payload/dist/admin/components/forms/useField/types'
|
||||||
|
import { TextareaField } from 'payload/dist/fields/config/types'
|
||||||
|
|
||||||
|
import { defaults } from '../defaults'
|
||||||
|
import { PluginConfig } from '../types'
|
||||||
|
import { LengthIndicator } from '../ui/LengthIndicator'
|
||||||
|
|
||||||
|
const { minLength, maxLength } = defaults.description
|
||||||
|
|
||||||
|
type TextareaFieldWithProps = TextareaField & {
|
||||||
|
path: string
|
||||||
|
pluginConfig: PluginConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MetaDescription: React.FC<
|
||||||
|
(TextareaFieldWithProps | {}) & {
|
||||||
|
pluginConfig: PluginConfig
|
||||||
|
}
|
||||||
|
> = props => {
|
||||||
|
const { path, label, name, pluginConfig } = (props as TextareaFieldWithProps) || {} // TODO: this typing is temporary until payload types are updated for custom field props
|
||||||
|
|
||||||
|
const locale = useLocale()
|
||||||
|
const [fields] = useAllFormFields()
|
||||||
|
const docInfo = useDocumentInfo()
|
||||||
|
|
||||||
|
const field: FieldType<string> = useField({
|
||||||
|
label,
|
||||||
|
name,
|
||||||
|
path,
|
||||||
|
} as Options)
|
||||||
|
|
||||||
|
const { value, setValue, showError } = field
|
||||||
|
|
||||||
|
const regenerateDescription = useCallback(async () => {
|
||||||
|
const { generateDescription } = pluginConfig
|
||||||
|
let generatedDescription
|
||||||
|
|
||||||
|
if (typeof generateDescription === 'function') {
|
||||||
|
generatedDescription = await generateDescription({
|
||||||
|
...docInfo,
|
||||||
|
doc: { ...fields },
|
||||||
|
locale,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
setValue(generatedDescription)
|
||||||
|
}, [fields, setValue, pluginConfig, locale, docInfo])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginBottom: '20px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginBottom: '5px',
|
||||||
|
position: 'relative',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
{label && typeof label === 'string' && label}
|
||||||
|
{typeof pluginConfig.generateDescription === 'function' && (
|
||||||
|
<>
|
||||||
|
—
|
||||||
|
<button
|
||||||
|
onClick={regenerateDescription}
|
||||||
|
type="button"
|
||||||
|
style={{
|
||||||
|
padding: 0,
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
cursor: 'pointer',
|
||||||
|
textDecoration: 'underline',
|
||||||
|
color: 'currentcolor',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Auto-generate
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
color: '#9A9A9A',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{`This should be between ${minLength} and ${maxLength} characters. For help in writing quality meta descriptions, see `}
|
||||||
|
<a
|
||||||
|
href="https://developers.google.com/search/docs/advanced/appearance/snippet#meta-descriptions"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
best practices
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginBottom: '10px',
|
||||||
|
position: 'relative',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TextareaInput
|
||||||
|
path={name}
|
||||||
|
name={name}
|
||||||
|
onChange={setValue}
|
||||||
|
value={value}
|
||||||
|
showError={showError}
|
||||||
|
style={{
|
||||||
|
marginBottom: 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
width: '100%',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<LengthIndicator text={value as string} minLength={minLength} maxLength={maxLength} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getMetaDescriptionField = (props: any) => <MetaDescription {...props} />
|
||||||
146
packages/plugin-seo/src/fields/MetaImage.tsx
Normal file
146
packages/plugin-seo/src/fields/MetaImage.tsx
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import React, { useCallback } from 'react'
|
||||||
|
import { useAllFormFields, useField } from 'payload/components/forms'
|
||||||
|
import { useConfig, useDocumentInfo, useLocale } from 'payload/components/utilities'
|
||||||
|
import UploadInput from 'payload/dist/admin/components/forms/field-types/Upload/Input'
|
||||||
|
import { Props as UploadFieldType } from 'payload/dist/admin/components/forms/field-types/Upload/types'
|
||||||
|
import { FieldType, Options } from 'payload/dist/admin/components/forms/useField/types'
|
||||||
|
|
||||||
|
import { PluginConfig } from '../types'
|
||||||
|
import { Pill } from '../ui/Pill'
|
||||||
|
|
||||||
|
type UploadFieldWithProps = UploadFieldType & {
|
||||||
|
path: string
|
||||||
|
pluginConfig: PluginConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MetaImage: React.FC<UploadFieldWithProps | {}> = props => {
|
||||||
|
const { label, relationTo, fieldTypes, name, pluginConfig } =
|
||||||
|
(props as UploadFieldWithProps) || {} // TODO: this typing is temporary until payload types are updated for custom field props
|
||||||
|
|
||||||
|
const field: FieldType<string> = useField(props as Options)
|
||||||
|
|
||||||
|
const locale = useLocale()
|
||||||
|
const [fields] = useAllFormFields()
|
||||||
|
const docInfo = useDocumentInfo()
|
||||||
|
|
||||||
|
const { value, setValue, showError } = field
|
||||||
|
|
||||||
|
const regenerateImage = useCallback(async () => {
|
||||||
|
const { generateImage } = pluginConfig
|
||||||
|
let generatedImage
|
||||||
|
|
||||||
|
if (typeof generateImage === 'function') {
|
||||||
|
generatedImage = await generateImage({
|
||||||
|
...docInfo,
|
||||||
|
doc: { ...fields },
|
||||||
|
locale,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
setValue(generatedImage)
|
||||||
|
}, [fields, setValue, pluginConfig, locale, docInfo])
|
||||||
|
|
||||||
|
const hasImage = Boolean(value)
|
||||||
|
|
||||||
|
const config = useConfig()
|
||||||
|
|
||||||
|
const { collections, serverURL, routes: { api } = {} } = config
|
||||||
|
|
||||||
|
const collection = collections?.find(coll => coll.slug === relationTo) || undefined
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginBottom: '20px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginBottom: '5px',
|
||||||
|
position: 'relative',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
{label && typeof label === 'string' && label}
|
||||||
|
{typeof pluginConfig.generateImage === 'function' && (
|
||||||
|
<>
|
||||||
|
—
|
||||||
|
<button
|
||||||
|
onClick={regenerateImage}
|
||||||
|
type="button"
|
||||||
|
style={{
|
||||||
|
padding: 0,
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
cursor: 'pointer',
|
||||||
|
textDecoration: 'underline',
|
||||||
|
color: 'currentcolor',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Auto-generate
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{typeof pluginConfig.generateImage === 'function' && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
color: '#9A9A9A',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Auto-generation will retrieve the selected hero image.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginBottom: '10px',
|
||||||
|
position: 'relative',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<UploadInput
|
||||||
|
path={name}
|
||||||
|
fieldTypes={fieldTypes}
|
||||||
|
name={name}
|
||||||
|
relationTo={relationTo}
|
||||||
|
value={value}
|
||||||
|
onChange={incomingImage => {
|
||||||
|
if (incomingImage !== null) {
|
||||||
|
const { id: incomingID } = incomingImage
|
||||||
|
setValue(incomingID)
|
||||||
|
} else {
|
||||||
|
setValue(null)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
label={undefined}
|
||||||
|
showError={showError}
|
||||||
|
api={api}
|
||||||
|
collection={collection}
|
||||||
|
serverURL={serverURL}
|
||||||
|
filterOptions={{}}
|
||||||
|
style={{
|
||||||
|
marginBottom: 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
width: '100%',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Pill
|
||||||
|
backgroundColor={hasImage ? 'green' : 'red'}
|
||||||
|
color="white"
|
||||||
|
label={hasImage ? 'Good' : 'No Image'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getMetaImageField = (props: any) => <MetaImage {...props} />
|
||||||
132
packages/plugin-seo/src/fields/MetaTitle.tsx
Normal file
132
packages/plugin-seo/src/fields/MetaTitle.tsx
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import React, { useCallback } from 'react'
|
||||||
|
import { useAllFormFields, useField } from 'payload/components/forms'
|
||||||
|
import { useDocumentInfo, useLocale } from 'payload/components/utilities'
|
||||||
|
import TextInputField from 'payload/dist/admin/components/forms/field-types/Text/Input'
|
||||||
|
import { Props as TextFieldType } from 'payload/dist/admin/components/forms/field-types/Text/types'
|
||||||
|
import { FieldType as FieldType, Options } from 'payload/dist/admin/components/forms/useField/types'
|
||||||
|
|
||||||
|
import { defaults } from '../defaults'
|
||||||
|
import { PluginConfig } from '../types'
|
||||||
|
import { LengthIndicator } from '../ui/LengthIndicator'
|
||||||
|
|
||||||
|
const { minLength, maxLength } = defaults.title
|
||||||
|
|
||||||
|
type TextFieldWithProps = TextFieldType & {
|
||||||
|
path: string
|
||||||
|
pluginConfig: PluginConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MetaTitle: React.FC<TextFieldWithProps | {}> = props => {
|
||||||
|
const { label, name, path, pluginConfig } = (props as TextFieldWithProps) || {} // TODO: this typing is temporary until payload types are updated for custom field props
|
||||||
|
|
||||||
|
const field: FieldType<string> = useField({
|
||||||
|
label,
|
||||||
|
name,
|
||||||
|
path,
|
||||||
|
} as Options)
|
||||||
|
|
||||||
|
const locale = useLocale()
|
||||||
|
const [fields] = useAllFormFields()
|
||||||
|
const docInfo = useDocumentInfo()
|
||||||
|
|
||||||
|
const { value, setValue, showError } = field
|
||||||
|
|
||||||
|
const regenerateTitle = useCallback(async () => {
|
||||||
|
const { generateTitle } = pluginConfig
|
||||||
|
let generatedTitle
|
||||||
|
|
||||||
|
if (typeof generateTitle === 'function') {
|
||||||
|
generatedTitle = await generateTitle({
|
||||||
|
...docInfo,
|
||||||
|
doc: { ...fields },
|
||||||
|
locale,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
setValue(generatedTitle)
|
||||||
|
}, [fields, setValue, pluginConfig, locale, docInfo])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginBottom: '20px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginBottom: '5px',
|
||||||
|
position: 'relative',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
{label && typeof label === 'string' && label}
|
||||||
|
{typeof pluginConfig.generateTitle === 'function' && (
|
||||||
|
<>
|
||||||
|
—
|
||||||
|
<button
|
||||||
|
onClick={regenerateTitle}
|
||||||
|
type="button"
|
||||||
|
style={{
|
||||||
|
padding: 0,
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
cursor: 'pointer',
|
||||||
|
textDecoration: 'underline',
|
||||||
|
color: 'currentcolor',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Auto-generate
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
color: '#9A9A9A',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{`This should be between ${minLength} and ${maxLength} characters. For help in writing quality meta titles, see `}
|
||||||
|
<a
|
||||||
|
href="https://developers.google.com/search/docs/advanced/appearance/title-link#page-titles"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
best practices
|
||||||
|
</a>
|
||||||
|
.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'relative',
|
||||||
|
marginBottom: '10px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TextInputField
|
||||||
|
path={name}
|
||||||
|
name={name}
|
||||||
|
onChange={setValue}
|
||||||
|
value={value}
|
||||||
|
showError={showError}
|
||||||
|
style={{
|
||||||
|
marginBottom: 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
width: '100%',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<LengthIndicator text={value as string} minLength={minLength} maxLength={maxLength} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getMetaTitleField = (props: any) => <MetaTitle {...props} />
|
||||||
181
packages/plugin-seo/src/index.ts
Normal file
181
packages/plugin-seo/src/index.ts
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
import type { Config } from 'payload/config'
|
||||||
|
import type { Field, GroupField, TabsField } from 'payload/dist/fields/config/types'
|
||||||
|
|
||||||
|
import { getMetaDescriptionField } from './fields/MetaDescription'
|
||||||
|
import { getMetaImageField } from './fields/MetaImage'
|
||||||
|
import { getMetaTitleField } from './fields/MetaTitle'
|
||||||
|
import type { PluginConfig } from './types'
|
||||||
|
import { Overview } from './ui/Overview'
|
||||||
|
import { getPreviewField } from './ui/Preview'
|
||||||
|
|
||||||
|
const seo =
|
||||||
|
(pluginConfig: PluginConfig) =>
|
||||||
|
(config: Config): Config => {
|
||||||
|
const seoFields: GroupField[] = [
|
||||||
|
{
|
||||||
|
name: 'meta',
|
||||||
|
label: 'SEO',
|
||||||
|
type: 'group',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: 'overview',
|
||||||
|
label: 'Overview',
|
||||||
|
type: 'ui',
|
||||||
|
admin: {
|
||||||
|
components: {
|
||||||
|
Field: Overview,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'title',
|
||||||
|
type: 'text',
|
||||||
|
localized: true,
|
||||||
|
admin: {
|
||||||
|
components: {
|
||||||
|
Field: props => getMetaTitleField({ ...props, pluginConfig }),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'description',
|
||||||
|
type: 'textarea',
|
||||||
|
localized: true,
|
||||||
|
admin: {
|
||||||
|
components: {
|
||||||
|
Field: props => getMetaDescriptionField({ ...props, pluginConfig }),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
...(pluginConfig?.uploadsCollection
|
||||||
|
? [
|
||||||
|
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||||
|
{
|
||||||
|
name: 'image',
|
||||||
|
label: 'Meta Image',
|
||||||
|
type: 'upload',
|
||||||
|
localized: true,
|
||||||
|
relationTo: pluginConfig?.uploadsCollection,
|
||||||
|
admin: {
|
||||||
|
description:
|
||||||
|
'Maximum upload file size: 12MB. Recommended file size for images is <500KB.',
|
||||||
|
components: {
|
||||||
|
Field: props => getMetaImageField({ ...props, pluginConfig }),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as Field,
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
...(pluginConfig?.fields || []),
|
||||||
|
{
|
||||||
|
name: 'preview',
|
||||||
|
label: 'Preview',
|
||||||
|
type: 'ui',
|
||||||
|
admin: {
|
||||||
|
components: {
|
||||||
|
Field: props => getPreviewField({ ...props, pluginConfig }),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return {
|
||||||
|
...config,
|
||||||
|
collections:
|
||||||
|
config.collections?.map(collection => {
|
||||||
|
const { slug } = collection
|
||||||
|
const isEnabled = pluginConfig?.collections?.includes(slug)
|
||||||
|
|
||||||
|
if (isEnabled) {
|
||||||
|
if (pluginConfig?.tabbedUI) {
|
||||||
|
const seoTabs: TabsField[] = [
|
||||||
|
{
|
||||||
|
type: 'tabs',
|
||||||
|
tabs: [
|
||||||
|
// append a new tab onto the end of the tabs array, if there is one at the first index
|
||||||
|
// if needed, create a new `Content` tab in the first index for this collection's base fields
|
||||||
|
...(collection?.fields?.[0].type === 'tabs'
|
||||||
|
? collection.fields[0]?.tabs
|
||||||
|
: [
|
||||||
|
{
|
||||||
|
label: collection?.labels?.singular || 'Content',
|
||||||
|
fields: [...(collection?.fields || [])],
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
{
|
||||||
|
label: 'SEO',
|
||||||
|
fields: seoFields,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return {
|
||||||
|
...collection,
|
||||||
|
fields: [
|
||||||
|
...seoTabs,
|
||||||
|
...(collection?.fields?.[0].type === 'tabs' ? collection?.fields?.slice(1) : []),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...collection,
|
||||||
|
fields: [...(collection?.fields || []), ...seoFields],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return collection
|
||||||
|
}) || [],
|
||||||
|
globals:
|
||||||
|
config.globals?.map(global => {
|
||||||
|
const { slug } = global
|
||||||
|
const isEnabled = pluginConfig?.globals?.includes(slug)
|
||||||
|
|
||||||
|
if (isEnabled) {
|
||||||
|
if (pluginConfig?.tabbedUI) {
|
||||||
|
const seoTabs: TabsField[] = [
|
||||||
|
{
|
||||||
|
type: 'tabs',
|
||||||
|
tabs: [
|
||||||
|
// append a new tab onto the end of the tabs array, if there is one at the first index
|
||||||
|
// if needed, create a new `Content` tab in the first index for this global's base fields
|
||||||
|
...(global?.fields?.[0].type === 'tabs'
|
||||||
|
? global.fields[0]?.tabs
|
||||||
|
: [
|
||||||
|
{
|
||||||
|
label: global?.label || 'Content',
|
||||||
|
fields: [...(global?.fields || [])],
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
{
|
||||||
|
label: 'SEO',
|
||||||
|
fields: seoFields,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return {
|
||||||
|
...global,
|
||||||
|
fields: [
|
||||||
|
...seoTabs,
|
||||||
|
...(global?.fields?.[0].type === 'tabs' ? global?.fields?.slice(1) : []),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...global,
|
||||||
|
fields: [...(global?.fields || []), ...seoFields],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return global
|
||||||
|
}) || [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default seo
|
||||||
40
packages/plugin-seo/src/types.ts
Normal file
40
packages/plugin-seo/src/types.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import type { ContextType } from 'payload/dist/admin/components/utilities/DocumentInfo/types'
|
||||||
|
import type { Field } from 'payload/dist/fields/config/types'
|
||||||
|
|
||||||
|
export type GenerateTitle = <T = any>(
|
||||||
|
args: ContextType & { doc: T; locale?: string },
|
||||||
|
) => string | Promise<string>
|
||||||
|
|
||||||
|
export type GenerateDescription = <T = any>(
|
||||||
|
args: ContextType & {
|
||||||
|
doc: T
|
||||||
|
locale?: string
|
||||||
|
},
|
||||||
|
) => string | Promise<string>
|
||||||
|
|
||||||
|
export type GenerateImage = <T = any>(
|
||||||
|
args: ContextType & { doc: T; locale?: string },
|
||||||
|
) => string | Promise<string>
|
||||||
|
|
||||||
|
export type GenerateURL = <T = any>(
|
||||||
|
args: ContextType & { doc: T; locale?: string },
|
||||||
|
) => string | Promise<string>
|
||||||
|
|
||||||
|
export interface PluginConfig {
|
||||||
|
collections?: string[]
|
||||||
|
globals?: string[]
|
||||||
|
uploadsCollection?: string
|
||||||
|
fields?: Field[]
|
||||||
|
tabbedUI?: boolean
|
||||||
|
generateTitle?: GenerateTitle
|
||||||
|
generateDescription?: GenerateDescription
|
||||||
|
generateImage?: GenerateImage
|
||||||
|
generateURL?: GenerateURL
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Meta {
|
||||||
|
title?: string
|
||||||
|
description?: string
|
||||||
|
keywords?: string
|
||||||
|
image?: any // TODO: type this
|
||||||
|
}
|
||||||
129
packages/plugin-seo/src/ui/LengthIndicator.tsx
Normal file
129
packages/plugin-seo/src/ui/LengthIndicator.tsx
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import React, { Fragment, useEffect, useState } from 'react'
|
||||||
|
|
||||||
|
import { Pill } from './Pill'
|
||||||
|
|
||||||
|
export const LengthIndicator: React.FC<{
|
||||||
|
text?: string
|
||||||
|
minLength?: number
|
||||||
|
maxLength?: number
|
||||||
|
}> = props => {
|
||||||
|
const { text, minLength = 0, maxLength = 0 } = props
|
||||||
|
|
||||||
|
const [labelStyle, setLabelStyle] = useState({
|
||||||
|
color: '',
|
||||||
|
backgroundColor: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const [label, setLabel] = useState('')
|
||||||
|
const [barWidth, setBarWidth] = useState<number>(0)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const textLength = text?.length || 0
|
||||||
|
|
||||||
|
if (textLength === 0) {
|
||||||
|
setLabel('Missing')
|
||||||
|
setLabelStyle({
|
||||||
|
backgroundColor: 'red',
|
||||||
|
color: 'white',
|
||||||
|
})
|
||||||
|
setBarWidth(0)
|
||||||
|
} else {
|
||||||
|
const progress = (textLength - minLength) / (maxLength - minLength)
|
||||||
|
|
||||||
|
if (progress < 0) {
|
||||||
|
const ratioUntilMin = textLength / minLength
|
||||||
|
|
||||||
|
if (ratioUntilMin > 0.9) {
|
||||||
|
setLabel('Almost there')
|
||||||
|
setLabelStyle({
|
||||||
|
backgroundColor: 'orange',
|
||||||
|
color: 'white',
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
setLabel('Too short')
|
||||||
|
setLabelStyle({
|
||||||
|
backgroundColor: 'orangered',
|
||||||
|
color: 'white',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
setBarWidth(ratioUntilMin)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (progress >= 0 && progress <= 1) {
|
||||||
|
setLabel('Good')
|
||||||
|
setLabelStyle({
|
||||||
|
backgroundColor: 'green',
|
||||||
|
color: 'white',
|
||||||
|
})
|
||||||
|
setBarWidth(progress)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (progress > 1) {
|
||||||
|
setLabel('Too long')
|
||||||
|
setLabelStyle({
|
||||||
|
backgroundColor: 'red',
|
||||||
|
color: 'white',
|
||||||
|
})
|
||||||
|
setBarWidth(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [minLength, maxLength, text])
|
||||||
|
|
||||||
|
const textLength = text?.length || 0
|
||||||
|
|
||||||
|
const charsUntilMax = maxLength - textLength
|
||||||
|
const charsUntilMin = minLength - textLength
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Pill label={label} color={labelStyle.color} backgroundColor={labelStyle.backgroundColor} />
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginRight: '10px',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
flexShrink: 0,
|
||||||
|
lineHeight: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<small>
|
||||||
|
{`${text?.length || 0}/${minLength}-${maxLength} chars, `}
|
||||||
|
{(textLength === 0 || charsUntilMin > 0) && (
|
||||||
|
<Fragment>{`${charsUntilMin} to go`}</Fragment>
|
||||||
|
)}
|
||||||
|
{charsUntilMin <= 0 && charsUntilMax >= 0 && (
|
||||||
|
<Fragment>{`${charsUntilMax} left over`}</Fragment>
|
||||||
|
)}
|
||||||
|
{charsUntilMax < 0 && <Fragment>{`${charsUntilMax * -1} too many`}</Fragment>}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: '2px',
|
||||||
|
width: '100%',
|
||||||
|
backgroundColor: '#F3F3F3',
|
||||||
|
position: 'relative',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: '100%',
|
||||||
|
width: `${barWidth * 100}%`,
|
||||||
|
backgroundColor: labelStyle.backgroundColor,
|
||||||
|
position: 'absolute',
|
||||||
|
left: 0,
|
||||||
|
top: 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
59
packages/plugin-seo/src/ui/Overview.tsx
Normal file
59
packages/plugin-seo/src/ui/Overview.tsx
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import React, { useCallback, useEffect, useState } from 'react'
|
||||||
|
import { useAllFormFields, useForm } from 'payload/components/forms'
|
||||||
|
import { Field } from 'payload/dist/admin/components/forms/Form/types'
|
||||||
|
|
||||||
|
import { defaults } from '../defaults'
|
||||||
|
|
||||||
|
const {
|
||||||
|
title: { minLength: minTitle, maxLength: maxTitle },
|
||||||
|
description: { minLength: minDesc, maxLength: maxDesc },
|
||||||
|
} = defaults
|
||||||
|
|
||||||
|
export const Overview: React.FC = () => {
|
||||||
|
const { dispatchFields, getFields } = useForm()
|
||||||
|
|
||||||
|
const [
|
||||||
|
{
|
||||||
|
'meta.title': { value: metaTitle } = {} as Field,
|
||||||
|
'meta.description': { value: metaDesc } = {} as Field,
|
||||||
|
'meta.image': { value: metaImage } = {} as Field,
|
||||||
|
},
|
||||||
|
] = useAllFormFields()
|
||||||
|
|
||||||
|
const [titleIsValid, setTitleIsValid] = useState<boolean | undefined>()
|
||||||
|
const [descIsValid, setDescIsValid] = useState<boolean | undefined>()
|
||||||
|
const [imageIsValid, setImageIsValid] = useState<boolean | undefined>()
|
||||||
|
|
||||||
|
const resetAll = useCallback(() => {
|
||||||
|
const fields = getFields()
|
||||||
|
const fieldsWithoutMeta = fields
|
||||||
|
fieldsWithoutMeta['meta.title'].value = ''
|
||||||
|
fieldsWithoutMeta['meta.description'].value = ''
|
||||||
|
fieldsWithoutMeta['meta.image'].value = ''
|
||||||
|
// dispatchFields(fieldsWithoutMeta);
|
||||||
|
}, [getFields])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof metaTitle === 'string')
|
||||||
|
setTitleIsValid(metaTitle.length >= minTitle && metaTitle.length <= maxTitle)
|
||||||
|
if (typeof metaDesc === 'string')
|
||||||
|
setDescIsValid(metaDesc.length >= minDesc && metaDesc.length <= maxDesc)
|
||||||
|
setImageIsValid(Boolean(metaImage))
|
||||||
|
}, [metaTitle, metaDesc, metaImage])
|
||||||
|
|
||||||
|
const testResults = [titleIsValid, descIsValid, imageIsValid]
|
||||||
|
|
||||||
|
const numberOfPasses = testResults.filter(Boolean).length
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginBottom: '20px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>{`${numberOfPasses}/${testResults.length} checks are passing`}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
28
packages/plugin-seo/src/ui/Pill.tsx
Normal file
28
packages/plugin-seo/src/ui/Pill.tsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
export const Pill: React.FC<{
|
||||||
|
backgroundColor: string
|
||||||
|
color: string
|
||||||
|
label: string
|
||||||
|
}> = props => {
|
||||||
|
const { backgroundColor, color, label } = props
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '4px 6px',
|
||||||
|
backgroundColor,
|
||||||
|
color,
|
||||||
|
borderRadius: '2px',
|
||||||
|
marginRight: '10px',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
flexShrink: 0,
|
||||||
|
lineHeight: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<small>{label}</small>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
103
packages/plugin-seo/src/ui/Preview.tsx
Normal file
103
packages/plugin-seo/src/ui/Preview.tsx
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import React, { useEffect, useState } from 'react'
|
||||||
|
import { useAllFormFields } from 'payload/components/forms'
|
||||||
|
import { useDocumentInfo, useLocale } from 'payload/components/utilities'
|
||||||
|
import { Field } from 'payload/dist/admin/components/forms/Form/types'
|
||||||
|
|
||||||
|
import { PluginConfig } from '../types'
|
||||||
|
|
||||||
|
type PreviewFieldWithProps = Field & {
|
||||||
|
pluginConfig: PluginConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Preview: React.FC<PreviewFieldWithProps | {}> = props => {
|
||||||
|
const {
|
||||||
|
pluginConfig: { generateURL },
|
||||||
|
} = (props as PreviewFieldWithProps) || {} // TODO: this typing is temporary until payload types are updated for custom field props;
|
||||||
|
|
||||||
|
const locale = useLocale()
|
||||||
|
const [fields] = useAllFormFields()
|
||||||
|
const docInfo = useDocumentInfo()
|
||||||
|
|
||||||
|
const {
|
||||||
|
'meta.title': { value: metaTitle } = {} as Field,
|
||||||
|
'meta.description': { value: metaDescription } = {} as Field,
|
||||||
|
} = fields
|
||||||
|
|
||||||
|
const [href, setHref] = useState<string>()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const getHref = async () => {
|
||||||
|
if (typeof generateURL === 'function' && !href) {
|
||||||
|
const newHref = await generateURL({
|
||||||
|
...docInfo,
|
||||||
|
doc: { fields },
|
||||||
|
locale,
|
||||||
|
})
|
||||||
|
|
||||||
|
setHref(newHref)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
getHref()
|
||||||
|
}, [generateURL, fields, href, locale, docInfo])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div>Preview</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginBottom: '5px',
|
||||||
|
color: '#9A9A9A',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Exact result listings may vary based on content and search relevancy.
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '20px',
|
||||||
|
borderRadius: '5px',
|
||||||
|
boxShadow: '0px 0px 10px rgba(0, 0, 0, 0.1)',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
maxWidth: '600px',
|
||||||
|
width: '100%',
|
||||||
|
background: 'var(--theme-elevation-50)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<a
|
||||||
|
href={href}
|
||||||
|
style={{
|
||||||
|
textDecoration: 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{href || 'https://...'}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<h4
|
||||||
|
style={{
|
||||||
|
margin: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
href="/"
|
||||||
|
style={{
|
||||||
|
textDecoration: 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{metaTitle as string}
|
||||||
|
</a>
|
||||||
|
</h4>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
margin: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{metaDescription as string}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getPreviewField = (props: any) => <Preview {...props} />
|
||||||
18
packages/plugin-seo/tsconfig.json
Normal file
18
packages/plugin-seo/tsconfig.json
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "es5",
|
||||||
|
"outDir": "./dist",
|
||||||
|
"allowJs": true,
|
||||||
|
"module": "commonjs",
|
||||||
|
"sourceMap": true,
|
||||||
|
"jsx": "react",
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"declaration": true,
|
||||||
|
"declarationDir": "./dist",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src/**/*"
|
||||||
|
],
|
||||||
|
}
|
||||||
1
packages/plugin-seo/types.d.ts
vendored
Normal file
1
packages/plugin-seo/types.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './dist/types';
|
||||||
1
packages/plugin-seo/types.js
Normal file
1
packages/plugin-seo/types.js
Normal file
@@ -0,0 +1 @@
|
|||||||
|
module.exports = require('./dist/types');
|
||||||
7082
packages/plugin-seo/yarn.lock
Normal file
7082
packages/plugin-seo/yarn.lock
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user