feat(plugin-seo): export fields from plugin seo so that they can be imported freely in a collection fields config (#6996)

Exports the fields from the SEO plugin so that they can be used anywhere
inside a collection, new exports:

```ts
import { MetaDescriptionField, MetaImageField, MetaTitleField, OverviewField, PreviewField } from '@payloadcms/plugin-seo/fields'

// Used as fields
MetaImageField({
  relationTo: 'media',
  hasGenerateFn: true,
})

MetaDescriptionField({
  hasGenerateFn: true,
})

MetaTitleField({
  hasGenerateFn: true,
})

PreviewField({
  hasGenerateFn: true,
  titlePath: 'meta.title',
  descriptionPath: 'meta.description',
})

OverviewField({
  titlePath: 'meta.title',
  descriptionPath: 'meta.description',
  imagePath: 'meta.image',
})

```
This commit is contained in:
Paul
2024-07-02 09:53:52 -04:00
committed by GitHub
parent 0711f880ff
commit 25d368a7db
18 changed files with 492 additions and 48 deletions

View File

@@ -68,7 +68,7 @@ const config = buildConfig({
'pages',
],
uploadsCollection: 'media',
generateTitle: ({ doc }) => `Website.com — ${doc.title.value}`,
generateTitle: ({ doc }) => `Website.com — ${doc.title}`,
generateDescription: ({ doc }) => doc.excerpt
})
]
@@ -119,7 +119,7 @@ A function that allows you to return any meta title, including from document's c
{
// ...
seoPlugin({
generateTitle: ({ ...docInfo, doc, locale }) => `Website.com — ${doc?.title?.value}`,
generateTitle: ({ ...docInfo, doc, locale }) => `Website.com — ${doc?.title}`,
})
}
```
@@ -133,7 +133,7 @@ A function that allows you to return any meta description, including from docume
{
// ...
seoPlugin({
generateDescription: ({ ...docInfo, doc, locale }) => doc?.excerpt?.value,
generateDescription: ({ ...docInfo, doc, locale }) => doc?.excerpt,
})
}
```
@@ -147,7 +147,7 @@ A function that allows you to return any meta image, including from document's c
{
// ...
seoPlugin({
generateImage: ({ ...docInfo, doc, locale }) => doc?.featuredImage?.value,
generateImage: ({ ...docInfo, doc, locale }) => doc?.featuredImage,
})
}
```
@@ -162,7 +162,7 @@ A function called by the search preview component to display the actual URL of y
// ...
seoPlugin({
generateURL: ({ ...docInfo, doc, locale }) =>
`https://yoursite.com/${collection?.slug}/${doc?.slug?.value}`,
`https://yoursite.com/${collection?.slug}/${doc?.slug}`,
})
}
```
@@ -200,6 +200,54 @@ seoPlugin({
})
```
## Direct use of fields
There is the option to directly import any of the fields from the plugin so that you can include them anywhere as needed.
<Banner type="info">
You will still need to configure the plugin in the Payload config in order to configure the generation functions.
Since these fields are imported and used directly, they don't have access to the plugin config so they may need additional arguments to work the same way.
</Banner>
```ts
import { MetaDescriptionField, MetaImageField, MetaTitleField, OverviewField, PreviewField } from '@payloadcms/plugin-seo/fields'
// Used as fields
MetaImageField({
// the upload collection slug
relationTo: 'media',
// if the `generateImage` function is configured
hasGenerateFn: true,
})
MetaDescriptionField({
// if the `generateDescription` function is configured
hasGenerateFn: true,
})
MetaTitleField({
// if the `generateTitle` function is configured
hasGenerateFn: true,
})
PreviewField({
// if the `generateUrl` function is configured
hasGenerateFn: true,
// field paths to match the target field for data
titlePath: 'meta.title',
descriptionPath: 'meta.description',
})
OverviewField({
// field paths to match the target field for data
titlePath: 'meta.title',
descriptionPath: 'meta.description',
imagePath: 'meta.image',
})
```
## TypeScript
All types can be directly imported:
@@ -213,6 +261,18 @@ import {
} from '@payloadcms/plugin-seo/types';
```
You can then pass the collections from your generated Payload types into the generation types, for example:
```ts
import { Page } from './payload-types.ts';
import { GenerateTitle } from '@payloadcms/plugin-seo/types';
const generateTitle: GenerateTitle<Page> = async ({ doc, locale }) => {
return `Website.com — ${doc?.title}`
}
```
## Examples
The [Templates Directory](https://github.com/payloadcms/payload/tree/main/templates) contains an official [Website Template](https://github.com/payloadcms/payload/tree/main/templates/website) and [E-commerce Template](https://github.com/payloadcms/payload/tree/main/templates/ecommere) which demonstrates exactly how to configure this plugin in Payload and implement it on your front-end.

View File

@@ -29,6 +29,11 @@
"import": "./src/exports/types.ts",
"types": "./src/exports/types.ts",
"default": "./src/exports/types.ts"
},
"./fields": {
"import": "./src/exports/fields.ts",
"types": "./src/exports/fields.ts",
"default": "./src/exports/fields.ts"
}
},
"main": "./src/index.tsx",
@@ -73,6 +78,11 @@
"import": "./dist/exports/types.js",
"types": "./dist/exports/types.d.ts",
"default": "./dist/exports/types.js"
},
"./fields": {
"import": "./dist/exports/fields.js",
"types": "./dist/exports/fields.d.ts",
"default": "./dist/exports/fields.js"
}
},
"main": "./dist/index.js",

View File

@@ -0,0 +1,5 @@
export { MetaDescriptionField } from '../fields/MetaDescription/index.js'
export { MetaImageField } from '../fields/MetaImage/index.js'
export { MetaTitleField } from '../fields/MetaTitle/index.js'
export { OverviewField } from '../fields/Overview/index.js'
export { PreviewField } from '../fields/Preview/index.js'

View File

@@ -14,11 +14,11 @@ import {
} from '@payloadcms/ui'
import React, { useCallback } from 'react'
import type { PluginSEOTranslationKeys, PluginSEOTranslations } from '../translations/index.js'
import type { GenerateDescription } from '../types.js'
import type { PluginSEOTranslationKeys, PluginSEOTranslations } from '../../translations/index.js'
import type { GenerateDescription } from '../../types.js'
import { defaults } from '../defaults.js'
import { LengthIndicator } from '../ui/LengthIndicator.js'
import { defaults } from '../../defaults.js'
import { LengthIndicator } from '../../ui/LengthIndicator.js'
const { maxLength, minLength } = defaults.description
@@ -28,8 +28,8 @@ type MetaDescriptionProps = FormFieldBase & {
path: string
}
export const MetaDescription: React.FC<MetaDescriptionProps> = (props) => {
const { CustomLabel, hasGenerateDescriptionFn, label, labelProps, path, required } = props
export const MetaDescriptionComponent: React.FC<MetaDescriptionProps> = (props) => {
const { CustomLabel, hasGenerateDescriptionFn, label, labelProps, required } = props
const { path: pathFromContext } = useFieldProps()
const { t } = useTranslation<PluginSEOTranslations, PluginSEOTranslationKeys>()
@@ -39,7 +39,7 @@ export const MetaDescription: React.FC<MetaDescriptionProps> = (props) => {
const docInfo = useDocumentInfo()
const field: FieldType<string> = useField({
path,
path: pathFromContext,
} as Options)
const { errorMessage, setValue, showError, value } = field

View File

@@ -0,0 +1,35 @@
import type { TextareaField } from 'payload'
import { withMergedProps } from '@payloadcms/ui/shared'
import { MetaDescriptionComponent } from './MetaDescriptionComponent.js'
interface FieldFunctionProps {
/**
* Tell the component if the generate function is available as configured in the plugin config
*/
hasGenerateFn?: boolean
overrides?: Partial<TextareaField>
}
type FieldFunction = ({ hasGenerateFn, overrides }: FieldFunctionProps) => TextareaField
export const MetaDescriptionField: FieldFunction = ({ hasGenerateFn = false, overrides }) => {
return {
name: 'description',
type: 'textarea',
admin: {
components: {
Field: withMergedProps({
Component: MetaDescriptionComponent,
sanitizeServerOnlyProps: true,
toMergeIntoProps: {
hasGenerateDescriptionFn: hasGenerateFn,
},
}),
},
},
localized: true,
...((overrides as unknown as TextareaField) ?? {}),
}
}

View File

@@ -8,26 +8,28 @@ import {
useConfig,
useDocumentInfo,
useField,
useFieldProps,
useForm,
useLocale,
useTranslation,
} from '@payloadcms/ui'
import React, { useCallback } from 'react'
import type { PluginSEOTranslationKeys, PluginSEOTranslations } from '../translations/index.js'
import type { GenerateImage } from '../types.js'
import type { PluginSEOTranslationKeys, PluginSEOTranslations } from '../../translations/index.js'
import type { GenerateImage } from '../../types.js'
import { Pill } from '../ui/Pill.js'
import { Pill } from '../../ui/Pill.js'
// eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents
type MetaImageProps = UploadInputProps & {
hasGenerateImageFn: boolean
}
export const MetaImage: React.FC<MetaImageProps> = (props) => {
export const MetaImageComponent: React.FC<MetaImageProps> = (props) => {
const { CustomLabel, hasGenerateImageFn, label, labelProps, relationTo, required } = props || {}
const { path: pathFromContext } = useFieldProps()
const field: FieldType<string> = useField(props as Options)
const field: FieldType<string> = useField({ ...props, path: pathFromContext } as Options)
const { t } = useTranslation<PluginSEOTranslations, PluginSEOTranslationKeys>()

View File

@@ -0,0 +1,39 @@
import type { UploadField } from 'payload'
import { withMergedProps } from '@payloadcms/ui/shared'
import { MetaImageComponent } from './MetaImageComponent.js'
interface FieldFunctionProps {
/**
* Tell the component if the generate function is available as configured in the plugin config
*/
hasGenerateFn?: boolean
overrides?: Partial<UploadField>
relationTo: string
}
type FieldFunction = ({ hasGenerateFn, overrides }: FieldFunctionProps) => UploadField
export const MetaImageField: FieldFunction = ({ hasGenerateFn = false, overrides, relationTo }) => {
return {
name: 'image',
type: 'upload',
admin: {
components: {
Field: withMergedProps({
Component: MetaImageComponent,
sanitizeServerOnlyProps: true,
toMergeIntoProps: {
hasGenerateImageFn: hasGenerateFn,
},
}),
},
description: 'Maximum upload file size: 12MB. Recommended file size for images is <500KB.',
},
label: 'Meta Image',
localized: true,
relationTo,
...((overrides as unknown as UploadField) ?? {}),
}
}

View File

@@ -14,12 +14,12 @@ import {
} from '@payloadcms/ui'
import React, { useCallback } from 'react'
import type { PluginSEOTranslationKeys, PluginSEOTranslations } from '../translations/index.js'
import type { GenerateTitle } from '../types.js'
import type { PluginSEOTranslationKeys, PluginSEOTranslations } from '../../translations/index.js'
import type { GenerateTitle } from '../../types.js'
import { defaults } from '../defaults.js'
import { LengthIndicator } from '../ui/LengthIndicator.js'
import './index.scss'
import { defaults } from '../../defaults.js'
import { LengthIndicator } from '../../ui/LengthIndicator.js'
import '../index.scss'
const { maxLength, minLength } = defaults.title
@@ -28,14 +28,14 @@ type MetaTitleProps = FormFieldBase & {
hasGenerateTitleFn: boolean
}
export const MetaTitle: React.FC<MetaTitleProps> = (props) => {
const { CustomLabel, hasGenerateTitleFn, label, labelProps, path, required } = props || {}
export const MetaTitleComponent: React.FC<MetaTitleProps> = (props) => {
const { CustomLabel, hasGenerateTitleFn, label, labelProps, required } = props || {}
const { path: pathFromContext } = useFieldProps()
const { t } = useTranslation<PluginSEOTranslations, PluginSEOTranslationKeys>()
const field: FieldType<string> = useField({
path,
path: pathFromContext,
} as Options)
const locale = useLocale()

View File

@@ -0,0 +1,35 @@
import type { TextField } from 'payload'
import { withMergedProps } from '@payloadcms/ui/shared'
import { MetaTitleComponent } from './MetaTitleComponent.js'
interface FieldFunctionProps {
/**
* Tell the component if the generate function is available as configured in the plugin config
*/
hasGenerateFn?: boolean
overrides?: Partial<TextField>
}
type FieldFunction = ({ hasGenerateFn, overrides }: FieldFunctionProps) => TextField
export const MetaTitleField: FieldFunction = ({ hasGenerateFn = false, overrides }) => {
return {
name: 'title',
type: 'text',
admin: {
components: {
Field: withMergedProps({
Component: MetaTitleComponent,
sanitizeServerOnlyProps: true,
toMergeIntoProps: {
hasGenerateTitleFn: hasGenerateFn,
},
}),
},
},
localized: true,
...((overrides as unknown as TextField) ?? {}),
}
}

View File

@@ -1,30 +1,44 @@
'use client'
import type { FormField } from 'payload'
import type { FormField, UIField } from 'payload'
import { useAllFormFields, useForm, useTranslation } from '@payloadcms/ui'
import React, { useCallback, useEffect, useState } from 'react'
import type { PluginSEOTranslationKeys, PluginSEOTranslations } from '../translations/index.js'
import type { PluginSEOTranslationKeys, PluginSEOTranslations } from '../../translations/index.js'
import { defaults } from '../defaults.js'
import { defaults } from '../../defaults.js'
const {
description: { maxLength: maxDesc, minLength: minDesc },
title: { maxLength: maxTitle, minLength: minTitle },
} = defaults
export const Overview: React.FC = () => {
type OverviewProps = UIField & {
descriptionPath?: string
imagePath?: string
titlePath?: string
}
export const OverviewComponent: React.FC<OverviewProps> = ({
descriptionPath: descriptionPathFromContext,
imagePath: imagePathFromContext,
titlePath: titlePathFromContext,
}) => {
const {
// dispatchFields,
getFields,
} = useForm()
const descriptionPath = descriptionPathFromContext || 'meta.description'
const titlePath = titlePathFromContext || 'meta.title'
const imagePath = imagePathFromContext || 'meta.image'
const [
{
'meta.description': { value: metaDesc } = {} as FormField,
'meta.image': { value: metaImage } = {} as FormField,
'meta.title': { value: metaTitle } = {} as FormField,
[descriptionPath]: { value: metaDesc } = {} as FormField,
[imagePath]: { value: metaImage } = {} as FormField,
[titlePath]: { value: metaTitle } = {} as FormField,
},
] = useAllFormFields()
const { t } = useTranslation<PluginSEOTranslations, PluginSEOTranslationKeys>()

View File

@@ -0,0 +1,56 @@
import type { UIField } from 'payload'
import { withMergedProps } from '@payloadcms/ui/shared'
import { OverviewComponent } from './OverviewComponent.js'
interface FieldFunctionProps {
/**
* Path to the description field to use for the preview
*
* @default 'meta.description'
*/
descriptionPath?: string
/**
* Path to the image field to use for the preview
*
* @default 'meta.image'
*/
imagePath?: string
overrides?: Partial<UIField>
/**
* Path to the title field to use for the preview
*
* @default 'meta.title'
*/
titlePath?: string
}
type FieldFunction = ({ overrides }: FieldFunctionProps) => UIField
export const OverviewField: FieldFunction = ({
descriptionPath,
imagePath,
overrides,
titlePath,
}) => {
return {
name: 'overview',
type: 'ui',
admin: {
components: {
Field: withMergedProps({
Component: OverviewComponent,
sanitizeServerOnlyProps: true,
toMergeIntoProps: {
descriptionPath,
imagePath,
titlePath,
},
}),
},
},
label: 'Overview',
...((overrides as unknown as UIField) ?? {}),
}
}

View File

@@ -9,16 +9,23 @@ import {
useLocale,
useTranslation,
} from '@payloadcms/ui'
import { get } from 'http'
import React, { useEffect, useState } from 'react'
import type { PluginSEOTranslationKeys, PluginSEOTranslations } from '../translations/index.js'
import type { GenerateURL } from '../types.js'
import type { PluginSEOTranslationKeys, PluginSEOTranslations } from '../../translations/index.js'
import type { GenerateURL } from '../../types.js'
type PreviewProps = UIField & {
descriptionPath?: string
hasGenerateURLFn: boolean
titlePath?: string
}
export const Preview: React.FC<PreviewProps> = ({ hasGenerateURLFn }) => {
export const PreviewComponent: React.FC<PreviewProps> = ({
descriptionPath: descriptionPathFromContext,
hasGenerateURLFn,
titlePath: titlePathFromContext,
}) => {
const { t } = useTranslation<PluginSEOTranslations, PluginSEOTranslationKeys>()
const locale = useLocale()
@@ -26,9 +33,12 @@ export const Preview: React.FC<PreviewProps> = ({ hasGenerateURLFn }) => {
const { getData } = useForm()
const docInfo = useDocumentInfo()
const descriptionPath = descriptionPathFromContext || 'meta.description'
const titlePath = titlePathFromContext || 'meta.title'
const {
'meta.description': { value: metaDescription } = {} as FormField,
'meta.title': { value: metaTitle } = {} as FormField,
[descriptionPath]: { value: metaDescription } = {} as FormField,
[titlePath]: { value: metaTitle } = {} as FormField,
} = fields
const [href, setHref] = useState<string>()

View File

@@ -0,0 +1,54 @@
import type { UIField } from 'payload'
import { withMergedProps } from '@payloadcms/ui/shared'
import { PreviewComponent } from './PreviewComponent.js'
interface FieldFunctionProps {
/**
* Path to the description field to use for the preview
*
* @default 'meta.description'
*/
descriptionPath?: string
/**
* Tell the component if the generate function is available as configured in the plugin config
*/
hasGenerateFn?: boolean
overrides?: Partial<UIField>
/**
* Path to the title field to use for the preview
*
* @default 'meta.title'
*/
titlePath?: string
}
type FieldFunction = ({ hasGenerateFn, overrides }: FieldFunctionProps) => UIField
export const PreviewField: FieldFunction = ({
descriptionPath,
hasGenerateFn = false,
overrides,
titlePath,
}) => {
return {
name: 'preview',
type: 'ui',
admin: {
components: {
Field: withMergedProps({
Component: PreviewComponent,
sanitizeServerOnlyProps: true,
toMergeIntoProps: {
descriptionPath,
hasGenerateURLFn: hasGenerateFn,
titlePath,
},
}),
},
},
label: 'Preview',
...((overrides as unknown as UIField) ?? {}),
}
}

View File

@@ -12,12 +12,12 @@ import type {
SEOPluginConfig,
} from './types.js'
import { MetaDescription } from './fields/MetaDescription.js'
import { MetaImage } from './fields/MetaImage.js'
import { MetaTitle } from './fields/MetaTitle.js'
import { MetaDescriptionComponent } from './fields/MetaDescription/MetaDescriptionComponent.js'
import { MetaImageComponent } from './fields/MetaImage/MetaImageComponent.js'
import { MetaTitleComponent } from './fields/MetaTitle/MetaTitleComponent.js'
import { OverviewComponent } from './fields/Overview/OverviewComponent.js'
import { PreviewComponent } from './fields/Preview/PreviewComponent.js'
import { translations } from './translations/index.js'
import { Overview } from './ui/Overview.js'
import { Preview } from './ui/Preview.js'
export const seoPlugin =
(pluginConfig: SEOPluginConfig) =>
@@ -32,7 +32,7 @@ export const seoPlugin =
type: 'ui',
admin: {
components: {
Field: Overview,
Field: OverviewComponent,
},
},
label: 'Overview',
@@ -43,7 +43,7 @@ export const seoPlugin =
admin: {
components: {
Field: withMergedProps({
Component: MetaTitle,
Component: MetaTitleComponent,
sanitizeServerOnlyProps: true,
toMergeIntoProps: {
hasGenerateTitleFn: typeof pluginConfig?.generateTitle === 'function',
@@ -60,7 +60,7 @@ export const seoPlugin =
admin: {
components: {
Field: withMergedProps({
Component: MetaDescription,
Component: MetaDescriptionComponent,
sanitizeServerOnlyProps: true,
toMergeIntoProps: {
hasGenerateDescriptionFn:
@@ -81,7 +81,7 @@ export const seoPlugin =
admin: {
components: {
Field: withMergedProps({
Component: MetaImage,
Component: MetaImageComponent,
sanitizeServerOnlyProps: true,
toMergeIntoProps: {
hasGenerateImageFn: typeof pluginConfig?.generateImage === 'function',
@@ -105,7 +105,7 @@ export const seoPlugin =
admin: {
components: {
Field: withMergedProps({
Component: Preview,
Component: PreviewComponent,
sanitizeServerOnlyProps: true,
toMergeIntoProps: {
hasGenerateURLFn: typeof pluginConfig?.generateURL === 'function',

View File

@@ -0,0 +1,98 @@
import type { CollectionConfig } from 'payload'
import {
MetaDescriptionField,
MetaImageField,
MetaTitleField,
OverviewField,
PreviewField,
} from '@payloadcms/plugin-seo/fields'
import { pagesWithImportedFieldsSlug } from '../shared.js'
export const PagesWithImportedFields: CollectionConfig = {
slug: pagesWithImportedFieldsSlug,
labels: {
singular: 'Page with imported fields',
plural: 'Pages with imported fields',
},
admin: {
useAsTitle: 'title',
},
versions: {
drafts: true,
},
fields: [
{
name: 'title',
label: 'Title',
type: 'text',
required: true,
},
OverviewField({
titlePath: 'metaAndSEO.title',
descriptionPath: 'metaAndSEO.innerMeta.description',
imagePath: 'metaAndSEO.innerMedia.image',
}),
{
type: 'tabs',
tabs: [
{
label: 'General',
fields: [
{
name: 'excerpt',
label: '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',
},
},
],
},
{
label: 'Meta',
name: 'metaAndSEO',
fields: [
MetaTitleField({
hasGenerateFn: true,
}),
PreviewField({
hasGenerateFn: true,
titlePath: 'metaAndSEO.title',
descriptionPath: 'metaAndSEO.innerMeta.description',
}),
{
type: 'group',
name: 'innerMeta',
fields: [
MetaDescriptionField({
hasGenerateFn: true,
}),
],
},
{
type: 'group',
name: 'innerMedia',
fields: [
MetaImageField({
relationTo: 'media',
hasGenerateFn: true,
}),
],
},
],
},
],
},
],
}

View File

@@ -13,6 +13,7 @@ import { buildConfigWithDefaults } from '../buildConfigWithDefaults.js'
import { devUser } from '../credentials.js'
import { Media } from './collections/Media.js'
import { Pages } from './collections/Pages.js'
import { PagesWithImportedFields } from './collections/PagesWithImportedFields.js'
import { Users } from './collections/Users.js'
import { seed } from './seed/index.js'
@@ -29,7 +30,7 @@ const generateURL: GenerateURL<Page> = ({ doc, locale }) => {
}
export default buildConfigWithDefaults({
collections: [Users, Pages, Media],
collections: [Users, Pages, Media, PagesWithImportedFields],
i18n: {
supportedLanguages: {
en,

View File

@@ -11,6 +11,7 @@ export interface Config {
users: User;
pages: Page;
media: Media;
pagesWithImportedFields: PagesWithImportedField;
'payload-preferences': PayloadPreference;
'payload-migrations': PayloadMigration;
};
@@ -90,6 +91,28 @@ export interface Media {
focalX?: number | null;
focalY?: number | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "pagesWithImportedFields".
*/
export interface PagesWithImportedField {
id: string;
title: string;
excerpt?: string | null;
slug: string;
metaAndSEO?: {
title?: string | null;
innerMeta?: {
description?: string | null;
};
innerMedia?: {
image?: string | Media | null;
};
};
updatedAt: string;
createdAt: string;
_status?: ('draft' | 'published') | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-preferences".

View File

@@ -1,3 +1,5 @@
export const pagesSlug = 'pages'
export const pagesWithImportedFieldsSlug = 'pagesWithImportedFields'
export const mediaSlug = 'media'