Merge remote-tracking branch 'plugin-seo/main' into chore/plugin-seo

This commit is contained in:
Jacob Fletcher
2023-10-15 00:53:27 -04:00
34 changed files with 14590 additions and 0 deletions

View 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

View File

@@ -0,0 +1,3 @@
module.exports = {
extends: ['@payloadcms'],
}

7
packages/plugin-seo/.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
node_modules
.env
dist
demo/uploads
build
.DS_Store
package-lock.json

View File

@@ -0,0 +1,8 @@
module.exports = {
printWidth: 100,
parser: "typescript",
semi: false,
singleQuote: true,
trailingComma: "all",
arrowParens: "avoid",
};

View File

@@ -0,0 +1,206 @@
# Payload SEO Plugin
[![NPM](https://img.shields.io/npm/v/@payloadcms/plugin-seo)](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
![image](https://user-images.githubusercontent.com/70709113/163850633-f3da5f8e-2527-4688-bc79-17233307a883.png)
<!-- ![screenshot 1](https://github.com/@payloadcms/plugin-seo/blob/main/images/screenshot-1.jpg?raw=true) -->

View File

@@ -0,0 +1,2 @@
MONGODB_URI=mongodb://localhost/payload-plugin-seo
PAYLOAD_SECRET=kjnsaldkjfnasdljkfghbnseanljnuadlrigjandrg

View File

@@ -0,0 +1,4 @@
{
"ext": "ts",
"exec": "ts-node src/server.ts"
}

View 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"
}
}

View 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

View 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

View 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

View 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

View 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

View 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'),
},
})

View 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,
},
},
})
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

View 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()

View 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
}
}

File diff suppressed because it is too large Load Diff

View 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"
]
}

View File

@@ -0,0 +1,10 @@
export const defaults = {
title: {
minLength: 50,
maxLength: 60,
},
description: {
minLength: 100,
maxLength: 150,
},
}

View 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' && (
<>
&nbsp; &mdash; &nbsp;
<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} />

View 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' && (
<>
&nbsp; &mdash; &nbsp;
<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} />

View 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' && (
<>
&nbsp; &mdash; &nbsp;
<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} />

View 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

View 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
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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} />

View 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
View File

@@ -0,0 +1 @@
export * from './dist/types';

View File

@@ -0,0 +1 @@
module.exports = require('./dist/types');

File diff suppressed because it is too large Load Diff