Compare commits
2 Commits
main
...
feat/plugi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4345820bbd | ||
|
|
62ba27b3bf |
@@ -72,7 +72,8 @@
|
||||
"@payloadcms/next": "workspace:*",
|
||||
"@types/react": "19.0.1",
|
||||
"@types/react-dom": "19.0.1",
|
||||
"payload": "workspace:*"
|
||||
"payload": "workspace:*",
|
||||
"@payloadcms/richtext-lexical": "workspace:*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"payload": "workspace:*",
|
||||
|
||||
@@ -3,3 +3,5 @@ export { MetaImageComponent } from '../fields/MetaImage/MetaImageComponent.js'
|
||||
export { MetaTitleComponent } from '../fields/MetaTitle/MetaTitleComponent.js'
|
||||
export { OverviewComponent } from '../fields/Overview/OverviewComponent.js'
|
||||
export { PreviewComponent } from '../fields/Preview/PreviewComponent.js'
|
||||
export { InlineBlockComponent } from '../fields/MetaTitle/InlineBlockComponent.js'
|
||||
export { SEOFeature } from '../fields/MetaTitle/SEOFeature/index.client.js'
|
||||
|
||||
@@ -1,30 +1,73 @@
|
||||
import type { TextareaField } from 'payload'
|
||||
import type { RichTextField, TextareaField } from 'payload'
|
||||
|
||||
import { BlocksFeature, FixedToolbarFeature, lexicalEditor } from '@payloadcms/richtext-lexical'
|
||||
|
||||
import { SEOFeature } from '../MetaTitle/SEOFeature/index.js'
|
||||
|
||||
interface FieldFunctionProps {
|
||||
/**
|
||||
* Tell the component if the generate function is available as configured in the plugin config
|
||||
*/
|
||||
hasGenerateFn?: boolean
|
||||
overrides?: Partial<TextareaField>
|
||||
overrides?: Partial<RichTextField>
|
||||
}
|
||||
|
||||
type FieldFunction = ({ hasGenerateFn, overrides }: FieldFunctionProps) => TextareaField
|
||||
type FieldFunction = ({ hasGenerateFn, overrides }: FieldFunctionProps) => RichTextField
|
||||
|
||||
export const MetaDescriptionField: FieldFunction = ({ hasGenerateFn = false, overrides }) => {
|
||||
return {
|
||||
name: 'description',
|
||||
type: 'textarea',
|
||||
admin: {
|
||||
components: {
|
||||
Field: {
|
||||
clientProps: {
|
||||
hasGenerateDescriptionFn: hasGenerateFn,
|
||||
},
|
||||
path: '@payloadcms/plugin-seo/client#MetaDescriptionComponent',
|
||||
},
|
||||
type: 'richText',
|
||||
editor: lexicalEditor({
|
||||
admin: {
|
||||
hideGutter: true,
|
||||
},
|
||||
},
|
||||
features: [
|
||||
SEOFeature(),
|
||||
FixedToolbarFeature(),
|
||||
BlocksFeature({
|
||||
inlineBlocks: [
|
||||
{
|
||||
slug: 'Product Name',
|
||||
admin: {
|
||||
components: {
|
||||
Block: '@payloadcms/plugin-seo/client#InlineBlockComponent',
|
||||
},
|
||||
},
|
||||
fields: [],
|
||||
},
|
||||
{
|
||||
slug: 'Collection Name',
|
||||
admin: {
|
||||
components: {
|
||||
Block: '@payloadcms/plugin-seo/client#InlineBlockComponent',
|
||||
},
|
||||
},
|
||||
fields: [],
|
||||
},
|
||||
{
|
||||
slug: 'City',
|
||||
admin: {
|
||||
components: {
|
||||
Block: '@payloadcms/plugin-seo/client#InlineBlockComponent',
|
||||
},
|
||||
},
|
||||
fields: [],
|
||||
},
|
||||
{
|
||||
slug: 'Florist Name',
|
||||
admin: {
|
||||
components: {
|
||||
Block: '@payloadcms/plugin-seo/client#InlineBlockComponent',
|
||||
},
|
||||
},
|
||||
fields: [],
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
localized: true,
|
||||
...((overrides as unknown as TextareaField) ?? {}),
|
||||
...((overrides as unknown as RichTextField) ?? {}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import {
|
||||
InlineBlockContainer,
|
||||
InlineBlockLabel,
|
||||
InlineBlockRemoveButton,
|
||||
} from '@payloadcms/richtext-lexical/client'
|
||||
|
||||
export const InlineBlockComponent: React.FC = () => {
|
||||
return (
|
||||
<InlineBlockContainer>
|
||||
<InlineBlockLabel />
|
||||
<InlineBlockRemoveButton />
|
||||
</InlineBlockContainer>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
'use client'
|
||||
|
||||
import { useLexicalComposerContext } from '@payloadcms/richtext-lexical/lexical/react/LexicalComposerContext'
|
||||
import { useDocumentInfo, useTranslation } from '@payloadcms/ui'
|
||||
import React, { useEffect } from 'react'
|
||||
|
||||
import { defaults } from '../../../defaults.js'
|
||||
import { LengthIndicator } from '../../../ui/LengthIndicator.js'
|
||||
import { recurseEditorState } from '../../Preview/inlineBlockToText.js'
|
||||
|
||||
const { maxLength: maxLengthDefault, minLength: minLengthDefault } = defaults.title
|
||||
|
||||
export const LengthIndicatorPlugin = () => {
|
||||
const { t } = useTranslation<any, any>()
|
||||
|
||||
const docInfo = useDocumentInfo()
|
||||
|
||||
const minLength = minLengthDefault
|
||||
const maxLength = maxLengthDefault
|
||||
|
||||
const [editor] = useLexicalComposerContext()
|
||||
|
||||
const [editorText, setEditorText] = React.useState<null | string>('')
|
||||
|
||||
useEffect(() => {
|
||||
if (!editorText) {
|
||||
const editorState = editor.getEditorState().toJSON()
|
||||
|
||||
const text = []
|
||||
recurseEditorState(editorState?.root?.children, text, 0, docInfo.savedDocumentData as any)
|
||||
|
||||
setEditorText(text.join(' '))
|
||||
}
|
||||
|
||||
return editor.registerUpdateListener(({ editorState }) => {
|
||||
const editorStateJSON = editorState.toJSON()
|
||||
const text = []
|
||||
recurseEditorState(editorStateJSON?.root?.children, text, 0, docInfo.savedDocumentData as any)
|
||||
|
||||
setEditorText(text.join(' '))
|
||||
})
|
||||
}, [docInfo.savedDocumentData, editor, editorText, maxLength])
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
marginBottom: '20px',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
marginBottom: '5px',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
color: '#9A9A9A',
|
||||
}}
|
||||
>
|
||||
{t('plugin-seo:lengthTipTitle', { maxLength, minLength })}
|
||||
<a
|
||||
href="https://developers.google.com/search/docs/advanced/appearance/title-link#page-titles"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
{t('plugin-seo:bestPractices')}
|
||||
</a>
|
||||
.
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
alignItems: 'center',
|
||||
display: 'flex',
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
<LengthIndicator maxLength={maxLength} minLength={minLength} text={editorText} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
'use client'
|
||||
import { createClientFeature } from '@payloadcms/richtext-lexical/client'
|
||||
import { SEOPlugin } from './plugin.js'
|
||||
import { LengthIndicatorPlugin } from './LengthIndicatorPlugin.js'
|
||||
|
||||
export const SEOFeature = createClientFeature({
|
||||
plugins: [
|
||||
{
|
||||
Component: SEOPlugin,
|
||||
position: 'aboveContainer',
|
||||
},
|
||||
{
|
||||
Component: LengthIndicatorPlugin,
|
||||
position: 'belowContainer',
|
||||
},
|
||||
],
|
||||
})
|
||||
10
packages/plugin-seo/src/fields/MetaTitle/SEOFeature/index.ts
Normal file
10
packages/plugin-seo/src/fields/MetaTitle/SEOFeature/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { createServerFeature } from '@payloadcms/richtext-lexical'
|
||||
|
||||
export const SEOFeature = createServerFeature({
|
||||
key: 'seo',
|
||||
feature(props) {
|
||||
return {
|
||||
ClientFeature: '@payloadcms/plugin-seo/client#SEOFeature',
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,49 @@
|
||||
'use client'
|
||||
|
||||
import { useTranslation } from '@payloadcms/ui'
|
||||
import React from 'react'
|
||||
|
||||
export const SEOPlugin = () => {
|
||||
const { t } = useTranslation<any, any>()
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
marginBottom: '20px',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
marginBottom: '5px',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
<div className="plugin-seo__field">
|
||||
{false && (
|
||||
<React.Fragment>
|
||||
—
|
||||
<button
|
||||
disabled={false}
|
||||
onClick={() => {
|
||||
// void regenerateTitle()
|
||||
}}
|
||||
style={{
|
||||
background: 'none',
|
||||
backgroundColor: 'transparent',
|
||||
border: 'none',
|
||||
color: 'currentcolor',
|
||||
cursor: 'pointer',
|
||||
padding: 0,
|
||||
textDecoration: 'underline',
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
{t('plugin-seo:autoGenerate')}
|
||||
</button>
|
||||
</React.Fragment>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
import type { TextField } from 'payload'
|
||||
import type { RichTextField, TextField } from 'payload'
|
||||
import { BlocksFeature, FixedToolbarFeature, lexicalEditor } from '@payloadcms/richtext-lexical'
|
||||
import { SEOFeature } from './SEOFeature/index.js'
|
||||
|
||||
interface FieldFunctionProps {
|
||||
/**
|
||||
@@ -8,23 +10,65 @@ interface FieldFunctionProps {
|
||||
overrides?: Partial<TextField>
|
||||
}
|
||||
|
||||
type FieldFunction = ({ hasGenerateFn, overrides }: FieldFunctionProps) => TextField
|
||||
type FieldFunction = ({ hasGenerateFn, overrides }: FieldFunctionProps) => RichTextField
|
||||
|
||||
export const MetaTitleField: FieldFunction = ({ hasGenerateFn = false, overrides }) => {
|
||||
return {
|
||||
name: 'title',
|
||||
type: 'text',
|
||||
admin: {
|
||||
components: {
|
||||
Field: {
|
||||
clientProps: {
|
||||
hasGenerateTitleFn: hasGenerateFn,
|
||||
},
|
||||
path: '@payloadcms/plugin-seo/client#MetaTitleComponent',
|
||||
},
|
||||
type: 'richText',
|
||||
editor: lexicalEditor({
|
||||
admin: {
|
||||
hideGutter: true,
|
||||
},
|
||||
features: [
|
||||
SEOFeature(),
|
||||
FixedToolbarFeature(),
|
||||
BlocksFeature({
|
||||
inlineBlocks: [
|
||||
{
|
||||
slug: 'Product Name',
|
||||
fields: [],
|
||||
admin: {
|
||||
components: {
|
||||
Block: '@payloadcms/plugin-seo/client#InlineBlockComponent',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
slug: 'Collection Name',
|
||||
fields: [],
|
||||
admin: {
|
||||
components: {
|
||||
Block: '@payloadcms/plugin-seo/client#InlineBlockComponent',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
slug: 'City',
|
||||
fields: [],
|
||||
admin: {
|
||||
components: {
|
||||
Block: '@payloadcms/plugin-seo/client#InlineBlockComponent',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
slug: 'Florist Name',
|
||||
fields: [],
|
||||
admin: {
|
||||
components: {
|
||||
Block: '@payloadcms/plugin-seo/client#InlineBlockComponent',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
admin: {
|
||||
components: {},
|
||||
},
|
||||
localized: true,
|
||||
...((overrides as unknown as TextField) ?? {}),
|
||||
...((overrides as unknown as RichTextField) ?? {}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import type { FormField, UIField } from 'payload'
|
||||
import type { FormField, TypeWithID, UIField } from 'payload'
|
||||
|
||||
import {
|
||||
useAllFormFields,
|
||||
@@ -16,6 +16,8 @@ import React, { useEffect, useState } from 'react'
|
||||
import type { PluginSEOTranslationKeys, PluginSEOTranslations } from '../../translations/index.js'
|
||||
import type { GenerateURL } from '../../types.js'
|
||||
|
||||
import { recurseEditorState } from './inlineBlockToText.js'
|
||||
|
||||
type PreviewProps = {
|
||||
readonly descriptionPath?: string
|
||||
readonly hasGenerateURLFn: boolean
|
||||
@@ -91,6 +93,24 @@ export const PreviewComponent: React.FC<PreviewProps> = (props) => {
|
||||
}
|
||||
}, [fields, href, locale, docInfo, hasGenerateURLFn, getData, serverURL, api])
|
||||
|
||||
const metaTitleText = []
|
||||
|
||||
recurseEditorState(
|
||||
(metaTitle as any)?.root?.children ?? [],
|
||||
metaTitleText,
|
||||
0,
|
||||
docInfo.savedDocumentData as TypeWithID,
|
||||
)
|
||||
|
||||
const metaDescriptionText = []
|
||||
|
||||
recurseEditorState(
|
||||
(metaDescription as any)?.root?.children ?? [],
|
||||
metaDescriptionText,
|
||||
0,
|
||||
docInfo.savedDocumentData as TypeWithID,
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
@@ -138,7 +158,7 @@ export const PreviewComponent: React.FC<PreviewProps> = (props) => {
|
||||
textDecoration: 'none',
|
||||
}}
|
||||
>
|
||||
{metaTitle as string}
|
||||
{metaTitleText as React.ReactNode[]}
|
||||
</a>
|
||||
</h4>
|
||||
<p
|
||||
@@ -146,7 +166,7 @@ export const PreviewComponent: React.FC<PreviewProps> = (props) => {
|
||||
margin: 0,
|
||||
}}
|
||||
>
|
||||
{metaDescription as string}
|
||||
{metaDescriptionText as any}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
45
packages/plugin-seo/src/fields/Preview/inlineBlockToText.ts
Normal file
45
packages/plugin-seo/src/fields/Preview/inlineBlockToText.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import type { TypeWithID } from 'payload'
|
||||
|
||||
export const inlineBlockToText = (args: { documentData: TypeWithID; inlineBlock: any }) => {
|
||||
if (args.inlineBlock.fields.blockType === 'Product Name') {
|
||||
// @ts-expect-error
|
||||
return args.documentData.productName
|
||||
}
|
||||
if (args.inlineBlock.fields.blockType === 'Collection Name') {
|
||||
// @ts-expect-error
|
||||
return args.documentData.collectionName
|
||||
}
|
||||
if (args.inlineBlock.fields.blockType === 'City') {
|
||||
// @ts-expect-error
|
||||
return args.documentData.city
|
||||
}
|
||||
if (args.inlineBlock.fields.blockType === 'Florist Name') {
|
||||
// @ts-expect-error
|
||||
return args.documentData.floristName
|
||||
}
|
||||
return 'Inline Block'
|
||||
}
|
||||
|
||||
export function recurseEditorState(
|
||||
editorState: any[],
|
||||
textContent: string[],
|
||||
i: number = 0,
|
||||
documentData: TypeWithID,
|
||||
): string[] {
|
||||
for (const node of editorState) {
|
||||
i++
|
||||
if (node?.type === 'inlineBlock') {
|
||||
textContent.push(inlineBlockToText({ documentData, inlineBlock: node }))
|
||||
} else if ('text' in node && node.text) {
|
||||
textContent.push(node.text as string)
|
||||
} else {
|
||||
if (!('children' in node)) {
|
||||
textContent.push(node.type)
|
||||
}
|
||||
}
|
||||
if ('children' in node && node.children) {
|
||||
textContent = recurseEditorState(node.children as any[], textContent, i, documentData)
|
||||
}
|
||||
}
|
||||
return textContent
|
||||
}
|
||||
@@ -21,5 +21,5 @@
|
||||
"src/**/*.spec.tsx"
|
||||
],
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.d.ts", "src/**/*.json"],
|
||||
"references": [{ "path": "../payload" }, { "path": "../ui" }, { "path": "../next" }]
|
||||
"references": [{ "path": "../payload" }, { "path": "../ui" }, { "path": "../next" }, { "path": "../richtext-lexical" }]
|
||||
}
|
||||
|
||||
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
@@ -1132,6 +1132,9 @@ importers:
|
||||
'@payloadcms/next':
|
||||
specifier: workspace:*
|
||||
version: link:../next
|
||||
'@payloadcms/richtext-lexical':
|
||||
specifier: workspace:*
|
||||
version: link:../richtext-lexical
|
||||
'@types/react':
|
||||
specifier: 19.0.1
|
||||
version: 19.0.1
|
||||
|
||||
@@ -15,6 +15,22 @@ export const Pages: CollectionConfig = {
|
||||
drafts: true,
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'productName',
|
||||
type: 'text',
|
||||
},
|
||||
{
|
||||
name: 'collectionName',
|
||||
type: 'text',
|
||||
},
|
||||
{
|
||||
name: 'city',
|
||||
type: 'text',
|
||||
},
|
||||
{
|
||||
name: 'floristName',
|
||||
type: 'text',
|
||||
},
|
||||
{
|
||||
type: 'tabs',
|
||||
tabs: [
|
||||
|
||||
@@ -70,26 +70,8 @@ export default buildConfigWithDefaults({
|
||||
seoPlugin({
|
||||
collections: ['pages'],
|
||||
fields: ({ defaultFields }) => {
|
||||
const modifiedFields = defaultFields.map((field) => {
|
||||
if ('name' in field && field.name === 'title') {
|
||||
return {
|
||||
...field,
|
||||
required: true,
|
||||
admin: {
|
||||
...field.admin,
|
||||
components: {
|
||||
...field.admin.components,
|
||||
afterInput: '/components/AfterInput.js#AfterInput',
|
||||
beforeInput: '/components/BeforeInput.js#BeforeInput',
|
||||
},
|
||||
},
|
||||
} as Field
|
||||
}
|
||||
return field
|
||||
})
|
||||
|
||||
return [
|
||||
...modifiedFields,
|
||||
...defaultFields,
|
||||
{
|
||||
name: 'ogTitle',
|
||||
type: 'text',
|
||||
|
||||
@@ -84,12 +84,44 @@ export interface User {
|
||||
*/
|
||||
export interface Page {
|
||||
id: string;
|
||||
productName?: string | null;
|
||||
collectionName?: string | null;
|
||||
city?: string | null;
|
||||
floristName?: string | null;
|
||||
title: string;
|
||||
excerpt?: string | null;
|
||||
slug: string;
|
||||
meta: {
|
||||
title: string;
|
||||
description?: string | null;
|
||||
meta?: {
|
||||
title?: {
|
||||
root: {
|
||||
type: string;
|
||||
children: {
|
||||
type: string;
|
||||
version: number;
|
||||
[k: string]: unknown;
|
||||
}[];
|
||||
direction: ('ltr' | 'rtl') | null;
|
||||
format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | '';
|
||||
indent: number;
|
||||
version: number;
|
||||
};
|
||||
[k: string]: unknown;
|
||||
} | null;
|
||||
description?: {
|
||||
root: {
|
||||
type: string;
|
||||
children: {
|
||||
type: string;
|
||||
version: number;
|
||||
[k: string]: unknown;
|
||||
}[];
|
||||
direction: ('ltr' | 'rtl') | null;
|
||||
format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | '';
|
||||
indent: number;
|
||||
version: number;
|
||||
};
|
||||
[k: string]: unknown;
|
||||
} | null;
|
||||
image?: (string | null) | Media;
|
||||
ogTitle?: string | null;
|
||||
};
|
||||
@@ -141,9 +173,37 @@ export interface PagesWithImportedField {
|
||||
excerpt?: string | null;
|
||||
slug: string;
|
||||
metaAndSEO: {
|
||||
title: string;
|
||||
title: {
|
||||
root: {
|
||||
type: string;
|
||||
children: {
|
||||
type: string;
|
||||
version: number;
|
||||
[k: string]: unknown;
|
||||
}[];
|
||||
direction: ('ltr' | 'rtl') | null;
|
||||
format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | '';
|
||||
indent: number;
|
||||
version: number;
|
||||
};
|
||||
[k: string]: unknown;
|
||||
};
|
||||
innerMeta?: {
|
||||
description?: string | null;
|
||||
description?: {
|
||||
root: {
|
||||
type: string;
|
||||
children: {
|
||||
type: string;
|
||||
version: number;
|
||||
[k: string]: unknown;
|
||||
}[];
|
||||
direction: ('ltr' | 'rtl') | null;
|
||||
format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | '';
|
||||
indent: number;
|
||||
version: number;
|
||||
};
|
||||
[k: string]: unknown;
|
||||
} | null;
|
||||
};
|
||||
innerMedia?: {
|
||||
image?: (string | null) | Media;
|
||||
@@ -238,6 +298,10 @@ export interface UsersSelect<T extends boolean = true> {
|
||||
* via the `definition` "pages_select".
|
||||
*/
|
||||
export interface PagesSelect<T extends boolean = true> {
|
||||
productName?: T;
|
||||
collectionName?: T;
|
||||
city?: T;
|
||||
floristName?: T;
|
||||
title?: T;
|
||||
excerpt?: T;
|
||||
slug?: T;
|
||||
|
||||
Reference in New Issue
Block a user