Compare commits

...

2 Commits

Author SHA1 Message Date
Alessio Gravili
4345820bbd Merge remote-tracking branch 'origin/main' into feat/plugin-seo-placeholders-lexical 2024-12-13 11:55:14 -07:00
Alessio Gravili
62ba27b3bf plugin-seo with placeholders and lexical 2024-12-13 11:55:00 -07:00
16 changed files with 448 additions and 55 deletions

View File

@@ -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:*",

View File

@@ -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'

View File

@@ -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) ?? {}),
}
}

View File

@@ -0,0 +1,14 @@
import {
InlineBlockContainer,
InlineBlockLabel,
InlineBlockRemoveButton,
} from '@payloadcms/richtext-lexical/client'
export const InlineBlockComponent: React.FC = () => {
return (
<InlineBlockContainer>
<InlineBlockLabel />
<InlineBlockRemoveButton />
</InlineBlockContainer>
)
}

View File

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

View File

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

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

View File

@@ -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>
&nbsp; &mdash; &nbsp;
<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>
)
}

View File

@@ -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) ?? {}),
}
}

View File

@@ -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>

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

View File

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

@@ -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

View File

@@ -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: [

View File

@@ -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',

View File

@@ -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;