chore: initializes standalone repository
This commit is contained in:
1
packages/plugin-seo/.gitignore
vendored
1
packages/plugin-seo/.gitignore
vendored
@@ -1,3 +1,4 @@
|
|||||||
node_modules
|
node_modules
|
||||||
.env
|
.env
|
||||||
dist
|
dist
|
||||||
|
demo/uploads
|
||||||
|
|||||||
106
packages/plugin-seo/README.md
Normal file
106
packages/plugin-seo/README.md
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
# Payload SEO Plugin
|
||||||
|
|
||||||
|
[](https://www.npmjs.com/package/payload-plugin-seo)
|
||||||
|
|
||||||
|
A plugin for [Payload CMS](https://github.com/payloadcms/payload) to auto-generate custom meta data based on the content of your documents.
|
||||||
|
|
||||||
|
Core features:
|
||||||
|
- Adds a `meta` field to every seo-enabled collection that:
|
||||||
|
- Includes title, description, and image subfields
|
||||||
|
- Auto-generates meta data from your document's content
|
||||||
|
- Displays hints and indicators to help content editors
|
||||||
|
- Renders a snippet of what a search engine might display
|
||||||
|
- Soon: variable injection
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
yarn add payload-plugin-seo
|
||||||
|
# OR
|
||||||
|
npm i payload-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 'payload-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}`,
|
||||||
|
generateDescription: ({ doc }) => doc.excerpt
|
||||||
|
})
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
export default config;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Options
|
||||||
|
|
||||||
|
- `collections`
|
||||||
|
|
||||||
|
An array of collections slugs to enable SEO. Enabled collections receive a `meta` field which is an object of title, description, and image subfields.
|
||||||
|
|
||||||
|
|
||||||
|
- `uploadsCollection`
|
||||||
|
|
||||||
|
An upload-enabled collection slug, for the meta image to access.
|
||||||
|
|
||||||
|
- `generateTitle`
|
||||||
|
|
||||||
|
Lorem
|
||||||
|
|
||||||
|
```js
|
||||||
|
seo({
|
||||||
|
...
|
||||||
|
generateTitle: ({ doc }) => `Website.com — ${doc.title}`,
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
- `generateDescription`
|
||||||
|
|
||||||
|
Lorem
|
||||||
|
|
||||||
|
```js
|
||||||
|
seo({
|
||||||
|
...
|
||||||
|
generateDescription: ({ doc }) => doc.excerpt
|
||||||
|
})
|
||||||
|
|
||||||
|
## TypeScript
|
||||||
|
|
||||||
|
All types can be directly imported:
|
||||||
|
|
||||||
|
```js
|
||||||
|
import {
|
||||||
|
SEOConfig,
|
||||||
|
GenerateTitle,
|
||||||
|
GenerateDescription
|
||||||
|
} from 'payload-plugin-seo/dist/types';
|
||||||
|
```
|
||||||
|
|
||||||
|
## Screenshots
|
||||||
|
|
||||||
|
<!--  -->
|
||||||
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_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": {
|
||||||
|
"payload": "^0.14.24-beta.0",
|
||||||
|
"dotenv": "^8.2.0",
|
||||||
|
"express": "^4.17.1"
|
||||||
|
},
|
||||||
|
"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 { CollectionConfig } from 'payload/types';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
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;
|
||||||
20
packages/plugin-seo/demo/src/collections/Pages.ts
Normal file
20
packages/plugin-seo/demo/src/collections/Pages.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { CollectionConfig } from 'payload/types';
|
||||||
|
|
||||||
|
const Pages: CollectionConfig = {
|
||||||
|
slug: 'pages',
|
||||||
|
admin: {
|
||||||
|
useAsTitle: 'title',
|
||||||
|
},
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: 'title',
|
||||||
|
type: 'text',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'excerpt',
|
||||||
|
type: 'text',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Pages;
|
||||||
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 { 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;
|
||||||
48
packages/plugin-seo/demo/src/payload.config.ts
Normal file
48
packages/plugin-seo/demo/src/payload.config.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { buildConfig } from 'payload/config';
|
||||||
|
import path from 'path';
|
||||||
|
import seo from '../../dist';
|
||||||
|
// import seo from '../../src';
|
||||||
|
import Users from './collections/Users';
|
||||||
|
import Pages from './collections/Pages';
|
||||||
|
import Media from './collections/Media';
|
||||||
|
|
||||||
|
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,
|
||||||
|
Media
|
||||||
|
],
|
||||||
|
plugins: [
|
||||||
|
seo({
|
||||||
|
collections: [
|
||||||
|
'pages',
|
||||||
|
],
|
||||||
|
uploadsCollection: 'media',
|
||||||
|
generateTitle: ({ doc }) => `Website.com — ${doc.title}`,
|
||||||
|
generateDescription: ({ doc }) => doc.excerpt
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
typescript: {
|
||||||
|
outputFile: path.resolve(__dirname, 'payload-types.ts')
|
||||||
|
},
|
||||||
|
});
|
||||||
24
packages/plugin-seo/demo/src/server.ts
Normal file
24
packages/plugin-seo/demo/src/server.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import payload from 'payload';
|
||||||
|
|
||||||
|
require('dotenv').config();
|
||||||
|
const app = express();
|
||||||
|
|
||||||
|
// Redirect root to Admin panel
|
||||||
|
app.get('/', (_, res) => {
|
||||||
|
res.redirect('/admin');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initialize Payload
|
||||||
|
payload.init({
|
||||||
|
secret: process.env.PAYLOAD_SECRET,
|
||||||
|
mongoURL: process.env.MONGODB_URI,
|
||||||
|
express: app,
|
||||||
|
onInit: () => {
|
||||||
|
payload.logger.info(`Payload Admin URL: ${payload.getAdminURL()}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add your own express routes here
|
||||||
|
|
||||||
|
app.listen(3000);
|
||||||
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": "./src",
|
||||||
|
"jsx": "react",
|
||||||
|
},
|
||||||
|
"ts-node": {
|
||||||
|
"transpileOnly": true
|
||||||
|
}
|
||||||
|
}
|
||||||
57
packages/plugin-seo/demo/yarn-error.log
Normal file
57
packages/plugin-seo/demo/yarn-error.log
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
Arguments:
|
||||||
|
/Users/jacobfletcher/.nvm/versions/node/v14.18.0/bin/node /Users/jacobfletcher/.nvm/versions/node/v14.18.0/bin/yarn
|
||||||
|
|
||||||
|
PATH:
|
||||||
|
/Users/jacobfletcher/.nvm/versions/node/v14.18.0/bin:/Users/jacobfletcher/.yarn/bin:/Users/jacobfletcher/.config/yarn/global/node_modules/.bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:/Users/jacobfletcher/.nvm/versions/node/v14.18.0/bin:/Users/jacobfletcher/.yarn/bin:/Users/jacobfletcher/.config/yarn/global/node_modules/.bin
|
||||||
|
|
||||||
|
Yarn version:
|
||||||
|
1.22.17
|
||||||
|
|
||||||
|
Node version:
|
||||||
|
14.18.0
|
||||||
|
|
||||||
|
Platform:
|
||||||
|
darwin x64
|
||||||
|
|
||||||
|
Trace:
|
||||||
|
SyntaxError: /Users/jacobfletcher/dev/trbl/payload-plugin-seo/package.json: Unexpected token } in JSON at position 652
|
||||||
|
at JSON.parse (<anonymous>)
|
||||||
|
at /Users/jacobfletcher/.nvm/versions/node/v14.18.0/lib/node_modules/yarn/lib/cli.js:1625:59
|
||||||
|
at Generator.next (<anonymous>)
|
||||||
|
at step (/Users/jacobfletcher/.nvm/versions/node/v14.18.0/lib/node_modules/yarn/lib/cli.js:310:30)
|
||||||
|
at /Users/jacobfletcher/.nvm/versions/node/v14.18.0/lib/node_modules/yarn/lib/cli.js:321:13
|
||||||
|
|
||||||
|
npm manifest:
|
||||||
|
{
|
||||||
|
"name": "payload-starter-typescript",
|
||||||
|
"description": "Blank template - no collections",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"main": "dist/server.js",
|
||||||
|
"license": "MIT",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "cross-env 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": {
|
||||||
|
"payload": "^0.14.0",
|
||||||
|
"dotenv": "^8.2.0",
|
||||||
|
"express": "^4.17.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/express": "^4.17.9",
|
||||||
|
"cross-env": "^7.0.3",
|
||||||
|
"nodemon": "^2.0.6",
|
||||||
|
"ts-node": "^9.1.1",
|
||||||
|
"typescript": "^4.1.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
yarn manifest:
|
||||||
|
No manifest
|
||||||
|
|
||||||
|
Lockfile:
|
||||||
|
No lockfile
|
||||||
10615
packages/plugin-seo/demo/yarn.lock
Normal file
10615
packages/plugin-seo/demo/yarn.lock
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "payload-plugin-seo",
|
"name": "payload-plugin-seo",
|
||||||
"version": "1.0.0",
|
"version": "0.0.1",
|
||||||
"description": "SEO plugin for Payload CMS",
|
"description": "SEO plugin for Payload CMS",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"types": "dist/index.d.ts",
|
"types": "dist/index.d.ts",
|
||||||
@@ -31,4 +31,7 @@
|
|||||||
"files": [
|
"files": [
|
||||||
"dist"
|
"dist"
|
||||||
],
|
],
|
||||||
|
"dependencies": {
|
||||||
|
"@payloadcms/config-provider": "^1.0.0"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,9 @@
|
|||||||
/* eslint-disable no-use-before-define */
|
|
||||||
/* eslint-disable import/no-extraneous-dependencies */
|
|
||||||
import React, { useCallback } from 'react';
|
import React, { useCallback } from 'react';
|
||||||
import { Props as TextFieldType } from 'payload/dist/admin/components/forms/field-types/Text/types';
|
import { Props as TextFieldType } from 'payload/dist/admin/components/forms/field-types/Text/types';
|
||||||
import { useField, useWatchForm } from 'payload/components/forms';
|
import { useField, useWatchForm } from 'payload/components/forms';
|
||||||
import { FieldType, Options } from 'payload/dist/admin/components/forms/useField/types';
|
import { FieldType, Options } from 'payload/dist/admin/components/forms/useField/types';
|
||||||
import { LengthIndicator } from '../ui/LengthIndicator';
|
import { LengthIndicator } from '../ui/LengthIndicator';
|
||||||
import { defaults } from '../defaults';
|
import { defaults } from '../defaults';
|
||||||
import { generateMetaDescription } from '../utilities/generateMetaDescription';
|
|
||||||
import TextareaInput from 'payload/dist/admin/components/forms/field-types/Textarea/Input';
|
import TextareaInput from 'payload/dist/admin/components/forms/field-types/Textarea/Input';
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -30,9 +27,17 @@ export const MetaDescription: React.FC<TextFieldType> = (props) => {
|
|||||||
showError
|
showError
|
||||||
} = field;
|
} = field;
|
||||||
|
|
||||||
|
let generateDescription: string | ((doc: any) => void);
|
||||||
|
|
||||||
const regenerateDescription = useCallback(() => {
|
const regenerateDescription = useCallback(() => {
|
||||||
const generatedDesc = generateMetaDescription(fields);
|
const getDescription = async () => {
|
||||||
setValue(generatedDesc);
|
let generatedDescription;
|
||||||
|
if (typeof generateDescription === 'function') {
|
||||||
|
generatedDescription = await generateDescription({ fields });
|
||||||
|
}
|
||||||
|
setValue(generatedDescription);
|
||||||
|
}
|
||||||
|
getDescription();
|
||||||
}, [
|
}, [
|
||||||
fields,
|
fields,
|
||||||
setValue,
|
setValue,
|
||||||
|
|||||||
@@ -1,12 +1,9 @@
|
|||||||
/* eslint-disable no-use-before-define */
|
|
||||||
/* eslint-disable import/no-extraneous-dependencies */
|
|
||||||
import React, { useCallback } from 'react';
|
import React, { useCallback } from 'react';
|
||||||
import { useConfig } from '@payloadcms/config-provider';
|
import { useConfig } from '@payloadcms/config-provider';
|
||||||
import { Props as UploadFieldType } from 'payload/dist/admin/components/forms/field-types/Upload/types';
|
import { Props as UploadFieldType } from 'payload/dist/admin/components/forms/field-types/Upload/types';
|
||||||
import UploadInput from 'payload/dist/admin/components/forms/field-types/Upload/Input';
|
import UploadInput from 'payload/dist/admin/components/forms/field-types/Upload/Input';
|
||||||
import { useField, useWatchForm } from 'payload/components/forms';
|
import { useField, useWatchForm } from 'payload/components/forms';
|
||||||
import { FieldType, Options } from 'payload/dist/admin/components/forms/useField/types';
|
import { FieldType, Options } from 'payload/dist/admin/components/forms/useField/types';
|
||||||
import { generateMetaImage } from '../utilities/generateMetaImage';
|
|
||||||
import { Pill } from '../ui/Pill';
|
import { Pill } from '../ui/Pill';
|
||||||
|
|
||||||
export const MetaImage: React.FC<UploadFieldType> = (props) => {
|
export const MetaImage: React.FC<UploadFieldType> = (props) => {
|
||||||
@@ -27,9 +24,17 @@ export const MetaImage: React.FC<UploadFieldType> = (props) => {
|
|||||||
showError,
|
showError,
|
||||||
} = field;
|
} = field;
|
||||||
|
|
||||||
|
let generateImage: string | ((doc: any) => void);
|
||||||
|
|
||||||
const regenerateImage = useCallback(() => {
|
const regenerateImage = useCallback(() => {
|
||||||
const generatedImage = generateMetaImage(fields);
|
const getDescription = async () => {
|
||||||
setValue(generatedImage);
|
let generatedImage;
|
||||||
|
if (typeof generateImage === 'function') {
|
||||||
|
generatedImage = await generateImage({ fields });
|
||||||
|
}
|
||||||
|
setValue(generatedImage);
|
||||||
|
}
|
||||||
|
getDescription();
|
||||||
}, [
|
}, [
|
||||||
fields,
|
fields,
|
||||||
setValue,
|
setValue,
|
||||||
@@ -37,7 +42,7 @@ export const MetaImage: React.FC<UploadFieldType> = (props) => {
|
|||||||
|
|
||||||
const hasImage = Boolean(value);
|
const hasImage = Boolean(value);
|
||||||
|
|
||||||
const config = useConfig();
|
const config = useConfig(); // TODO: this returns an empty object
|
||||||
|
|
||||||
const {
|
const {
|
||||||
collections,
|
collections,
|
||||||
@@ -109,7 +114,7 @@ export const MetaImage: React.FC<UploadFieldType> = (props) => {
|
|||||||
setValue(null);
|
setValue(null);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
label={null}
|
label={undefined}
|
||||||
showError={showError}
|
showError={showError}
|
||||||
api={api}
|
api={api}
|
||||||
collection={collection}
|
collection={collection}
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
/* eslint-disable no-use-before-define */
|
|
||||||
/* eslint-disable import/no-extraneous-dependencies */
|
|
||||||
import React, { useCallback } from 'react';
|
import React, { useCallback } from 'react';
|
||||||
import { Props as TextFieldType } from 'payload/dist/admin/components/forms/field-types/Text/types';
|
import { Props as TextFieldType } from 'payload/dist/admin/components/forms/field-types/Text/types';
|
||||||
import TextInputField from 'payload/dist/admin/components/forms/field-types/Text/Input';
|
import TextInputField from 'payload/dist/admin/components/forms/field-types/Text/Input';
|
||||||
@@ -7,7 +5,6 @@ import { useField, useWatchForm } from 'payload/components/forms';
|
|||||||
import { FieldType as FieldType, Options } from 'payload/dist/admin/components/forms/useField/types';
|
import { FieldType as FieldType, Options } from 'payload/dist/admin/components/forms/useField/types';
|
||||||
import { LengthIndicator } from '../ui/LengthIndicator';
|
import { LengthIndicator } from '../ui/LengthIndicator';
|
||||||
import { defaults } from '../defaults';
|
import { defaults } from '../defaults';
|
||||||
import { generateMetaTitle } from '../utilities/generateMetaTitle';
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
minLength,
|
minLength,
|
||||||
@@ -30,9 +27,14 @@ export const MetaTitle: React.FC<TextFieldType> = (props) => {
|
|||||||
showError
|
showError
|
||||||
} = field;
|
} = field;
|
||||||
|
|
||||||
|
let generateTitle: string | ((doc: any) => void);
|
||||||
|
|
||||||
const regenerateTitle = useCallback(() => {
|
const regenerateTitle = useCallback(() => {
|
||||||
const getTitle = async () => {
|
const getTitle = async () => {
|
||||||
const generatedTitle = await generateMetaTitle(fields);
|
let generatedTitle;
|
||||||
|
if (typeof generateTitle === 'function') {
|
||||||
|
generatedTitle = await generateTitle({ fields });
|
||||||
|
}
|
||||||
setValue(generatedTitle);
|
setValue(generatedTitle);
|
||||||
}
|
}
|
||||||
getTitle();
|
getTitle();
|
||||||
|
|||||||
@@ -5,82 +5,82 @@ import { Overview } from './ui/Overview';
|
|||||||
import { MetaTitle } from './fields/MetaTitle';
|
import { MetaTitle } from './fields/MetaTitle';
|
||||||
import { Preview } from './ui/Preview';
|
import { Preview } from './ui/Preview';
|
||||||
import { MetaImage } from './fields/MetaImage';
|
import { MetaImage } from './fields/MetaImage';
|
||||||
|
import { SEOConfig } from './types';
|
||||||
|
|
||||||
type Options = {
|
const seo = (seoConfig: SEOConfig) => (config: Config): Config => ({
|
||||||
collections?: string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
const seo = (options: Options) => (config: Config): Config => ({
|
|
||||||
...config,
|
...config,
|
||||||
collections: config.collections.map((collection) => {
|
collections: config.collections?.map((collection) => {
|
||||||
const { slug } = collection;
|
const { slug } = collection;
|
||||||
const addMeta = options?.collections?.includes(slug);
|
const isEnabled = seoConfig?.collections?.includes(slug);
|
||||||
|
|
||||||
return ({
|
if (isEnabled) {
|
||||||
...collection,
|
return ({
|
||||||
fields: [
|
...collection,
|
||||||
...collection.fields,
|
fields: [
|
||||||
...addMeta ? [{
|
...collection?.fields || [],
|
||||||
name: 'meta',
|
{
|
||||||
label: 'SEO',
|
name: 'meta',
|
||||||
type: 'group',
|
label: 'SEO',
|
||||||
fields: [
|
type: 'group',
|
||||||
{
|
fields: [
|
||||||
name: 'overview',
|
{
|
||||||
label: 'Overview',
|
name: 'overview',
|
||||||
type: 'ui',
|
label: 'Overview',
|
||||||
admin: {
|
type: 'ui',
|
||||||
components: {
|
admin: {
|
||||||
Field: Overview,
|
components: {
|
||||||
|
Field: Overview,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
{
|
||||||
{
|
name: 'title',
|
||||||
name: 'title',
|
label: 'Meta Title',
|
||||||
label: 'Meta Title',
|
type: 'text',
|
||||||
type: 'text',
|
admin: {
|
||||||
admin: {
|
components: {
|
||||||
components: {
|
Field: MetaTitle,
|
||||||
Field: MetaTitle,
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
{
|
||||||
{
|
name: 'description',
|
||||||
name: 'description',
|
label: 'Meta Description',
|
||||||
label: 'Meta Description',
|
type: 'textarea',
|
||||||
type: 'textarea',
|
admin: {
|
||||||
admin: {
|
components: {
|
||||||
components: {
|
Field: MetaDescription,
|
||||||
Field: MetaDescription,
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
{
|
||||||
{
|
name: 'image',
|
||||||
name: 'image',
|
label: 'Meta Image',
|
||||||
label: 'Meta Image',
|
type: 'upload',
|
||||||
type: 'upload',
|
relationTo: seoConfig?.uploadsCollection || '',
|
||||||
relationTo: 'media',
|
admin: {
|
||||||
admin: {
|
description: 'Maximum upload file size: 12MB. Recommended file size for images is <500KB.',
|
||||||
description: 'Maximum upload file size: 12MB. Recommended file size for images is <500KB.',
|
components: {
|
||||||
components: {
|
Field: MetaImage,
|
||||||
Field: MetaImage,
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
{
|
||||||
{
|
name: 'preview',
|
||||||
name: 'preview',
|
label: 'Preview',
|
||||||
label: 'Preview',
|
type: 'ui',
|
||||||
type: 'ui',
|
admin: {
|
||||||
admin: {
|
components: {
|
||||||
components: {
|
Field: Preview,
|
||||||
Field: Preview,
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
],
|
||||||
],
|
},
|
||||||
}] : [],
|
],
|
||||||
],
|
}) as CollectionConfig;
|
||||||
}) as CollectionConfig;
|
}
|
||||||
|
return collection;
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
6
packages/plugin-seo/src/types.ts
Normal file
6
packages/plugin-seo/src/types.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export type SEOConfig = {
|
||||||
|
collections?: string[]
|
||||||
|
uploadsCollection?: string
|
||||||
|
generateTitle?: (args: { doc: any }) => string | Promise<string>
|
||||||
|
generateDescription?: (args: { doc: any }) => string | Promise<string>
|
||||||
|
}
|
||||||
@@ -11,8 +11,8 @@ export const LengthIndicator: React.FC<{
|
|||||||
}> = (props) => {
|
}> = (props) => {
|
||||||
const {
|
const {
|
||||||
text,
|
text,
|
||||||
minLength,
|
minLength = 0,
|
||||||
maxLength,
|
maxLength = 0,
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
const [labelStyle, setLabelStyle] = useState({
|
const [labelStyle, setLabelStyle] = useState({
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ export const Preview: React.FC = () => {
|
|||||||
textDecoration: 'none',
|
textDecoration: 'none',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{metaTitle}
|
{metaTitle as string}
|
||||||
</a>
|
</a>
|
||||||
</h4>
|
</h4>
|
||||||
<p
|
<p
|
||||||
@@ -70,7 +70,7 @@ export const Preview: React.FC = () => {
|
|||||||
color: '#202124',
|
color: '#202124',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{metaDescription}
|
{metaDescription as string}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,65 +0,0 @@
|
|||||||
import getDataByPath from 'payload/dist/admin/components/forms/Form/getDataByPath';
|
|
||||||
import { Fields } from 'payload/dist/admin/components/forms/Form/types';
|
|
||||||
import { stringifyRichText } from './stringifyRichText';
|
|
||||||
|
|
||||||
export const generateMetaDescription = (fields: Fields): string => {
|
|
||||||
const {
|
|
||||||
excerpt: {
|
|
||||||
value: excerpt,
|
|
||||||
},
|
|
||||||
} = fields;
|
|
||||||
|
|
||||||
let description = excerpt as string || '';
|
|
||||||
|
|
||||||
if (!excerpt) {
|
|
||||||
const firstBlock = getDataByPath(fields, 'layout.0');
|
|
||||||
|
|
||||||
if (firstBlock) {
|
|
||||||
// instead of writing a custom block for every type, we'll just iterate threw some generic keys and write a block each of those
|
|
||||||
const commonKeys = [
|
|
||||||
'introContent',
|
|
||||||
'richText',
|
|
||||||
'columns',
|
|
||||||
];
|
|
||||||
|
|
||||||
const blockKeys = Object.keys(firstBlock);
|
|
||||||
const matchingKeys = commonKeys.filter((commonKey) => blockKeys.includes(commonKey));
|
|
||||||
|
|
||||||
if (matchingKeys.length > -1) {
|
|
||||||
const keyToUse = matchingKeys[0];
|
|
||||||
const field = firstBlock?.[keyToUse];
|
|
||||||
|
|
||||||
if (field) {
|
|
||||||
if (keyToUse === 'introContent') {
|
|
||||||
const introContent = stringifyRichText(field);
|
|
||||||
if (!description && introContent) description = introContent;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (keyToUse === 'richText') {
|
|
||||||
const richText = stringifyRichText(field);
|
|
||||||
if (!description && richText) description = richText;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (keyToUse === 'columns') {
|
|
||||||
const firstColumn = field[0];
|
|
||||||
if (firstColumn) {
|
|
||||||
const {
|
|
||||||
richText,
|
|
||||||
} = firstColumn;
|
|
||||||
|
|
||||||
let richTextToUse = richText;
|
|
||||||
if (typeof richText === 'string') {
|
|
||||||
richTextToUse = JSON.parse(richText);
|
|
||||||
}
|
|
||||||
|
|
||||||
const columnText = stringifyRichText(richTextToUse);
|
|
||||||
if (!description && columnText) description = columnText;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return description;
|
|
||||||
};
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
import { Fields } from 'payload/dist/admin/components/forms/Form/types';
|
|
||||||
|
|
||||||
export const generateMetaImage = (fields: Fields): string => {
|
|
||||||
let image;
|
|
||||||
|
|
||||||
const heroType = fields?.['hero.type']?.value;
|
|
||||||
|
|
||||||
if (heroType) {
|
|
||||||
image = fields?.[`hero.${heroType}.media`]?.value;
|
|
||||||
}
|
|
||||||
|
|
||||||
return image;
|
|
||||||
};
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
import { Fields } from 'payload/dist/admin/components/forms/Form/types';
|
|
||||||
|
|
||||||
const base = 'Hope Network';
|
|
||||||
|
|
||||||
export const generateMetaTitle = async (fields: Fields): Promise<string> => {
|
|
||||||
const {
|
|
||||||
title: {
|
|
||||||
value: docTitle,
|
|
||||||
},
|
|
||||||
subsite,
|
|
||||||
} = fields;
|
|
||||||
|
|
||||||
if (subsite) {
|
|
||||||
const {
|
|
||||||
value: subsiteID
|
|
||||||
} = subsite;
|
|
||||||
|
|
||||||
if (typeof subsiteID === 'string') {
|
|
||||||
try {
|
|
||||||
const req = await fetch(`${process.env.PAYLOAD_PUBLIC_SERVER_URL}/api/subsites/${subsiteID}`);
|
|
||||||
const doc = await req.json();
|
|
||||||
|
|
||||||
if (req.status === 200) {
|
|
||||||
const {
|
|
||||||
title: subsiteTitle
|
|
||||||
} = doc;
|
|
||||||
|
|
||||||
return `${base} ${subsiteTitle} - ${docTitle}`;
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error(`error while generating meta title, cannot find subsite with id: ${subsiteID}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return `${base} - ${docTitle}`
|
|
||||||
};
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
export const stringifyRichText = (richText) => {
|
|
||||||
let string = '';
|
|
||||||
|
|
||||||
richText.forEach((node) => {
|
|
||||||
const { children } = node;
|
|
||||||
|
|
||||||
children.forEach((child) => {
|
|
||||||
const { text } = child;
|
|
||||||
string = string.concat(' ', text);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
return string;
|
|
||||||
};
|
|
||||||
@@ -1596,11 +1596,6 @@
|
|||||||
dependencies:
|
dependencies:
|
||||||
"@types/node" "*"
|
"@types/node" "*"
|
||||||
|
|
||||||
"@types/escape-html@^1.0.1":
|
|
||||||
version "1.0.1"
|
|
||||||
resolved "https://registry.yarnpkg.com/@types/escape-html/-/escape-html-1.0.1.tgz#b19b4646915f0ae2c306bf984dc0a59c5cfc97ba"
|
|
||||||
integrity sha512-4mI1FuUUZiuT95fSVqvZxp/ssQK9zsa86S43h9x3zPOSU9BBJ+BfDkXwuaU7BfsD+e7U0/cUUfJFk3iW2M4okA==
|
|
||||||
|
|
||||||
"@types/eslint-scope@^3.7.3":
|
"@types/eslint-scope@^3.7.3":
|
||||||
version "3.7.3"
|
version "3.7.3"
|
||||||
resolved "https://registry.yarnpkg.com/@types/eslint-scope/-/eslint-scope-3.7.3.tgz#125b88504b61e3c8bc6f870882003253005c3224"
|
resolved "https://registry.yarnpkg.com/@types/eslint-scope/-/eslint-scope-3.7.3.tgz#125b88504b61e3c8bc6f870882003253005c3224"
|
||||||
@@ -2563,14 +2558,14 @@ browser-process-hrtime@^1.0.0:
|
|||||||
integrity sha512-9o5UecI3GhkpM6DrXr69PblIuWxPKk9Y0jHBRhdocZ2y7YECBFCsHm79Pr3OyR2AvjhDkabFJaDJMYRazHgsow==
|
integrity sha512-9o5UecI3GhkpM6DrXr69PblIuWxPKk9Y0jHBRhdocZ2y7YECBFCsHm79Pr3OyR2AvjhDkabFJaDJMYRazHgsow==
|
||||||
|
|
||||||
browserslist@^4.0.0, browserslist@^4.14.5, browserslist@^4.16.6, browserslist@^4.17.5, browserslist@^4.19.1:
|
browserslist@^4.0.0, browserslist@^4.14.5, browserslist@^4.16.6, browserslist@^4.17.5, browserslist@^4.19.1:
|
||||||
version "4.19.1"
|
version "4.19.3"
|
||||||
resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.19.1.tgz#4ac0435b35ab655896c31d53018b6dd5e9e4c9a3"
|
resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.19.3.tgz#29b7caad327ecf2859485f696f9604214bedd383"
|
||||||
integrity sha512-u2tbbG5PdKRTUoctO3NBD8FQ5HdPh1ZXPHzp1rwaa5jTc+RV9/+RlWiAIKmjRPQF+xbGM9Kklj5bZQFa2s/38A==
|
integrity sha512-XK3X4xtKJ+Txj8G5c30B4gsm71s69lqXlkYui4s6EkKxuv49qjYlY6oVd+IFJ73d4YymtM3+djvvt/R/iJwwDg==
|
||||||
dependencies:
|
dependencies:
|
||||||
caniuse-lite "^1.0.30001286"
|
caniuse-lite "^1.0.30001312"
|
||||||
electron-to-chromium "^1.4.17"
|
electron-to-chromium "^1.4.71"
|
||||||
escalade "^3.1.1"
|
escalade "^3.1.1"
|
||||||
node-releases "^2.0.1"
|
node-releases "^2.0.2"
|
||||||
picocolors "^1.0.0"
|
picocolors "^1.0.0"
|
||||||
|
|
||||||
bser@2.1.1:
|
bser@2.1.1:
|
||||||
@@ -2707,7 +2702,7 @@ caniuse-api@^3.0.0:
|
|||||||
lodash.memoize "^4.1.2"
|
lodash.memoize "^4.1.2"
|
||||||
lodash.uniq "^4.5.0"
|
lodash.uniq "^4.5.0"
|
||||||
|
|
||||||
caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001286, caniuse-lite@^1.0.30001297:
|
caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001297, caniuse-lite@^1.0.30001312:
|
||||||
version "1.0.30001312"
|
version "1.0.30001312"
|
||||||
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001312.tgz#e11eba4b87e24d22697dae05455d5aea28550d5f"
|
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001312.tgz#e11eba4b87e24d22697dae05455d5aea28550d5f"
|
||||||
integrity sha512-Wiz1Psk2MEK0pX3rUzWaunLTZzqS2JYZFzNKqAiJGiuxIjRPLgV6+VDPOg6lQOUxmDwhTlh198JsTTi8Hzw6aQ==
|
integrity sha512-Wiz1Psk2MEK0pX3rUzWaunLTZzqS2JYZFzNKqAiJGiuxIjRPLgV6+VDPOg6lQOUxmDwhTlh198JsTTi8Hzw6aQ==
|
||||||
@@ -3212,9 +3207,9 @@ css-what@^5.1.0:
|
|||||||
integrity sha512-arSMRWIIFY0hV8pIxZMEfmMI47Wj3R/aWpZDDxWYCPEiOMv6tfOrnpDtgxBYPEQD4V0Y/958+1TdC3iWTFcUPw==
|
integrity sha512-arSMRWIIFY0hV8pIxZMEfmMI47Wj3R/aWpZDDxWYCPEiOMv6tfOrnpDtgxBYPEQD4V0Y/958+1TdC3iWTFcUPw==
|
||||||
|
|
||||||
cssdb@^6.3.1:
|
cssdb@^6.3.1:
|
||||||
version "6.3.1"
|
version "6.4.0"
|
||||||
resolved "https://registry.yarnpkg.com/cssdb/-/cssdb-6.3.1.tgz#d8e521c70b32df082ea5373cdd51ac4dc6b6c151"
|
resolved "https://registry.yarnpkg.com/cssdb/-/cssdb-6.4.0.tgz#54899b9042e302be3090b8510ea71fefd08c9e6b"
|
||||||
integrity sha512-Ho3gIkGY4O8S3J54fHu7RP5GHWz85McDhimaXEwf7qV0MSPhLM0jdd61zqs1kkadIVDAvfqoku0kArbWaMYolw==
|
integrity sha512-8NMWrur/ewSNrRNZndbtOTXc2Xb2b+NCTPHj8VErFYvJUlgsMAiBGaFaxG6hjy9zbCjj2ZLwSQrMM+tormO8qA==
|
||||||
|
|
||||||
cssesc@^3.0.0:
|
cssesc@^3.0.0:
|
||||||
version "3.0.0"
|
version "3.0.0"
|
||||||
@@ -3635,7 +3630,7 @@ ee-first@1.1.1:
|
|||||||
resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d"
|
resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d"
|
||||||
integrity sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0=
|
integrity sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0=
|
||||||
|
|
||||||
electron-to-chromium@^1.4.17:
|
electron-to-chromium@^1.4.71:
|
||||||
version "1.4.71"
|
version "1.4.71"
|
||||||
resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.4.71.tgz#17056914465da0890ce00351a3b946fd4cd51ff6"
|
resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.4.71.tgz#17056914465da0890ce00351a3b946fd4cd51ff6"
|
||||||
integrity sha512-Hk61vXXKRb2cd3znPE9F+2pLWdIOmP7GjiTj45y6L3W/lO+hSnUSUhq+6lEaERWBdZOHbk2s3YV5c9xVl3boVw==
|
integrity sha512-Hk61vXXKRb2cd3znPE9F+2pLWdIOmP7GjiTj45y6L3W/lO+hSnUSUhq+6lEaERWBdZOHbk2s3YV5c9xVl3boVw==
|
||||||
@@ -3805,7 +3800,7 @@ escalade@^3.1.1:
|
|||||||
resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.1.1.tgz#d8cfdc7000965c5a0174b4a82eaa5c0552742e40"
|
resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.1.1.tgz#d8cfdc7000965c5a0174b4a82eaa5c0552742e40"
|
||||||
integrity sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==
|
integrity sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==
|
||||||
|
|
||||||
escape-html@^1.0.3, escape-html@~1.0.3:
|
escape-html@~1.0.3:
|
||||||
version "1.0.3"
|
version "1.0.3"
|
||||||
resolved "https://registry.yarnpkg.com/escape-html/-/escape-html-1.0.3.tgz#0258eae4d3d0c0974de1c169188ef0051d1d1988"
|
resolved "https://registry.yarnpkg.com/escape-html/-/escape-html-1.0.3.tgz#0258eae4d3d0c0974de1c169188ef0051d1d1988"
|
||||||
integrity sha1-Aljq5NPQwJdN4cFpGI7wBR0dGYg=
|
integrity sha1-Aljq5NPQwJdN4cFpGI7wBR0dGYg=
|
||||||
@@ -6682,7 +6677,7 @@ node-notifier@^8.0.0:
|
|||||||
uuid "^8.3.0"
|
uuid "^8.3.0"
|
||||||
which "^2.0.2"
|
which "^2.0.2"
|
||||||
|
|
||||||
node-releases@^2.0.1:
|
node-releases@^2.0.2:
|
||||||
version "2.0.2"
|
version "2.0.2"
|
||||||
resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.2.tgz#7139fe71e2f4f11b47d4d2986aaf8c48699e0c01"
|
resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.2.tgz#7139fe71e2f4f11b47d4d2986aaf8c48699e0c01"
|
||||||
integrity sha512-XxYDdcQ6eKqp/YjI+tb2C5WM2LgjnZrfYg4vgQt49EK268b6gYCHsBLrK2qvJo4FmCtqmKezb0WZFK4fkrZNsg==
|
integrity sha512-XxYDdcQ6eKqp/YjI+tb2C5WM2LgjnZrfYg4vgQt49EK268b6gYCHsBLrK2qvJo4FmCtqmKezb0WZFK4fkrZNsg==
|
||||||
|
|||||||
Reference in New Issue
Block a user