feat!: replaces admin.meta.ogImage with admin.meta.openGraph.images (#6227)

This commit is contained in:
Jacob Fletcher
2024-05-16 12:40:15 -04:00
committed by GitHub
parent a6bf05815c
commit 9556d1bd42
46 changed files with 622 additions and 223 deletions

View File

@@ -33,7 +33,7 @@ All options for the Admin panel are defined in your base Payload config file.
| `bundler` | The bundler that you would like to use to bundle the admin panel. Officially supported bundlers: [Webpack](/docs/admin/webpack) and [Vite](/docs/admin/vite). |
| `user` | The `slug` of a Collection that you want be used to log in to the Admin dashboard. [More](/docs/admin/overview#the-admin-user-collection) |
| `buildPath` | Specify an absolute path for where to store the built Admin panel bundle used in production. Defaults to `path.resolve(process.cwd(), 'build')`. |
| `meta` | Base meta data to use for the Admin panel. Included properties are `titleSuffix`, `ogImage`, and `favicon`. |
| `meta` | Base meta data to use for the Admin panel. Included properties are `titleSuffix`, `icons`, and `openGraph`. Can be overridden on a per collection or per global basis. |
| `disable` | If set to `true`, the entire Admin panel will be disabled. |
| `indexHTML` | Optionally replace the entirety of the `index.html` file used by the Admin panel. Reference the [base index.html file](https://github.com/payloadcms/payload/blob/main/packages/payload/src/admin/index.html) to ensure your replacement has the appropriate HTML elements. |
| `css` | Absolute path to a stylesheet that you can use to override / customize the Admin panel styling. [More](/docs/admin/customizing-css). |

View File

@@ -80,6 +80,7 @@ property on a collection's config.
| `hideAPIURL` | Hides the "API URL" meta field while editing documents within this collection. |
| `enableRichTextLink` | The [Rich Text](/docs/fields/rich-text) field features a `Link` element which allows for users to automatically reference related documents within their rich text. Set to `true` by default. |
| `enableRichTextRelationship` | The [Rich Text](/docs/fields/rich-text) field features a `Relationship` element which allows for users to automatically reference related documents within their rich text. Set to `true` by default. |
| `meta` | Metadata overrides to apply to the [Admin panel](../admin/overview). Included properties are `description` and `openGraph`. |
| `preview` | Function to generate preview URLS within the Admin panel that can point to your app. [More](#preview). |
| `livePreview` | Enable real-time editing for instant visual feedback of your front-end application. [More](/docs/live-preview/overview). |
| `components` | Swap in your own React components to be used within this collection. [More](/docs/admin/components#collections) |

View File

@@ -74,6 +74,7 @@ You can customize the way that the Admin panel behaves on a Global-by-Global bas
| `preview` | Function to generate a preview URL within the Admin panel for this global that can point to your app. [More](#preview). |
| `livePreview` | Enable real-time editing for instant visual feedback of your front-end application. [More](/docs/live-preview/overview). |
| `hideAPIURL` | Hides the "API URL" meta field while editing documents within this collection. |
| `meta` | Metadata overrides to apply to the [Admin panel](../admin/overview). Included properties are `description` and `openGraph`. |
### Preview

View File

@@ -56,6 +56,7 @@ import { findVersions as findVersionsGlobal } from './globals/findVersions.js'
import { preview as previewGlobal } from './globals/preview.js'
import { restoreVersion as restoreVersionGlobal } from './globals/restoreVersion.js'
import { update as updateGlobal } from './globals/update.js'
import { generateOGImage } from './og/index.js'
import { routeError } from './routeError.js'
const endpoints = {
@@ -113,6 +114,7 @@ const endpoints = {
root: {
GET: {
access,
og: generateOGImage,
},
POST: {
'form-state': buildFormState,

View File

@@ -0,0 +1,89 @@
import React from 'react'
export const OGImage: React.FC<{
Icon: React.ComponentType<any>
description?: string
fontFamily?: string
leader?: string
title?: string
}> = ({ Icon, description, fontFamily = 'Arial, sans-serif', leader, title }) => {
return (
<div
style={{
backgroundColor: '#000',
color: '#fff',
display: 'flex',
flexDirection: 'column',
fontFamily,
height: '100%',
justifyContent: 'space-between',
padding: '100px',
width: '100%',
}}
>
<div
style={{
display: 'flex',
flexDirection: 'column',
flexGrow: 1,
fontSize: 50,
height: '100%',
}}
>
{leader && (
<div
style={{
fontSize: 30,
marginBottom: 10,
}}
>
{leader}
</div>
)}
<p
style={{
WebkitBoxOrient: 'vertical',
WebkitLineClamp: 2,
display: '-webkit-box',
fontSize: 90,
lineHeight: 1,
marginBottom: 0,
marginTop: 0,
textOverflow: 'ellipsis',
}}
>
{title}
</p>
{description && (
<p
style={{
WebkitBoxOrient: 'vertical',
WebkitLineClamp: 2,
display: '-webkit-box',
flexGrow: 1,
fontSize: 30,
lineHeight: 1,
marginBottom: 0,
marginTop: 40,
textOverflow: 'ellipsis',
}}
>
{description}
</p>
)}
</div>
<div
style={{
alignItems: 'flex-end',
display: 'flex',
flexShrink: 0,
height: '38px',
justifyContent: 'center',
width: '38px',
}}
>
<Icon fill="white" />
</div>
</div>
)
}

View File

@@ -0,0 +1,81 @@
import type { PayloadRequestWithData } from 'payload/types'
import { PayloadIcon } from '@payloadcms/ui/graphics/Icon'
import fs from 'fs/promises'
import { ImageResponse } from 'next/og.js'
import { NextResponse } from 'next/server.js'
import path from 'path'
import React from 'react'
import { fileURLToPath } from 'url'
import { OGImage } from './image.js'
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
export const runtime = 'nodejs'
export const contentType = 'image/png'
export const generateOGImage = async ({ req }: { req: PayloadRequestWithData }) => {
const config = req.payload.config
if (config.admin.meta.defaultOGImageType === 'off') {
return NextResponse.json({ error: `Open Graph images are disabled` }, { status: 400 })
}
try {
const { searchParams } = new URL(req.url)
const hasTitle = searchParams.has('title')
const title = hasTitle ? searchParams.get('title')?.slice(0, 100) : ''
const hasLeader = searchParams.has('leader')
const leader = hasLeader ? searchParams.get('leader')?.slice(0, 100).replace('-', ' ') : ''
const description = searchParams.has('description') ? searchParams.get('description') : ''
const Icon = config.admin?.components?.graphics?.Icon || PayloadIcon
let fontData
try {
// TODO: replace with `.woff2` file when supported
// See https://github.com/vercel/next.js/issues/63935
// Or better yet, use a CDN like Google Fonts if ever supported
fontData = fs.readFile(path.join(dirname, 'roboto-regular.woff'))
} catch (e) {
console.error(`Error reading font file or not readable: ${e.message}`) // eslint-disable-line no-console
}
const fontFamily = 'Roboto, sans-serif'
return new ImageResponse(
(
<OGImage
Icon={Icon}
description={description}
fontFamily={fontFamily}
leader={leader}
title={title}
/>
),
{
...(fontData
? {
fonts: [
{
name: 'Roboto',
data: await fontData,
style: 'normal',
weight: 400,
},
],
}
: {}),
height: 630,
width: 1200,
},
)
} catch (e: any) {
console.error(`${e.message}`) // eslint-disable-line no-console
return NextResponse.json({ error: `Internal Server Error: ${e.message}` }, { status: 500 })
}
}

Binary file not shown.

View File

@@ -1,24 +1,29 @@
import type { Metadata } from 'next'
import type { Icon } from 'next/dist/lib/metadata/types/metadata-types.js'
import type { SanitizedConfig } from 'payload/types'
import type { MetaConfig } from 'payload/config'
import { payloadFaviconDark, payloadFaviconLight, payloadOgImage } from '@payloadcms/ui/assets'
import { staticOGImage } from '@payloadcms/ui/assets'
import { payloadFaviconDark, payloadFaviconLight } from '@payloadcms/ui/assets'
import QueryString from 'qs'
export const meta = async (args: {
config: SanitizedConfig
description?: string
keywords?: string
title: string
}): Promise<Metadata> => {
const { config, description = '', keywords = 'CMS, Admin, Dashboard', title } = args
const defaultOpenGraph = {
description:
'Payload is a headless CMS and application framework built with TypeScript, Node.js, and React.',
siteName: 'Payload App',
title: 'Payload App',
}
const titleSuffix = config.admin.meta?.titleSuffix ?? '- Payload'
const ogImage = config.admin?.meta?.ogImage ?? payloadOgImage?.src
const customIcons = config.admin.meta.icons as Metadata['icons']
let icons = customIcons ?? []
export const meta = async (args: MetaConfig & { serverURL: string }): Promise<any> => {
const {
defaultOGImageType,
description,
icons: customIcons,
keywords,
openGraph: openGraphFromProps,
serverURL,
title,
titleSuffix,
} = args
const payloadIcons: Icon[] = [
{
@@ -36,8 +41,52 @@ export const meta = async (args: {
},
]
let icons = customIcons ?? payloadIcons // TODO: fix this type assertion
if (customIcons && typeof customIcons === 'object' && Array.isArray(customIcons)) {
icons = payloadIcons.concat(customIcons)
icons = payloadIcons.concat(customIcons) // TODO: fix this type assertion
}
const metaTitle = `${title} ${titleSuffix}`
const ogTitle = `${typeof openGraphFromProps?.title === 'string' ? openGraphFromProps.title : title} ${titleSuffix}`
const mergedOpenGraph: Metadata['openGraph'] = {
...(defaultOpenGraph || {}),
...(defaultOGImageType === 'dynamic'
? {
images: [
{
alt: ogTitle,
height: 630,
url: `/api/og${QueryString.stringify(
{
description: openGraphFromProps?.description || defaultOpenGraph.description,
title: ogTitle,
},
{
addQueryPrefix: true,
},
)}`,
width: 1200,
},
],
}
: {}),
...(defaultOGImageType === 'static'
? {
images: [
{
alt: ogTitle,
height: 480,
url: staticOGImage.src,
width: 640,
},
],
}
: {}),
title: ogTitle,
...(openGraphFromProps || {}),
}
return Promise.resolve({
@@ -45,23 +94,11 @@ export const meta = async (args: {
icons,
keywords,
metadataBase: new URL(
config?.serverURL ||
serverURL ||
process.env.PAYLOAD_PUBLIC_SERVER_URL ||
`http://localhost:${process.env.PORT || 3000}`,
),
openGraph: {
type: 'website',
description,
images: [
{
alt: `${title} ${titleSuffix}`,
height: 630,
url: ogImage,
width: 1200,
},
],
title: `${title} ${titleSuffix}`,
},
title: `${title} ${titleSuffix}`,
openGraph: mergedOpenGraph,
title: metaTitle,
})
}

View File

@@ -1,11 +1,33 @@
import { getTranslation } from '@payloadcms/translations'
import type { GenerateEditViewMetadata } from '../Document/getMetaBySegment.js'
import { meta } from '../../utilities/meta.js'
export const generateMetadata: GenerateEditViewMetadata = async ({ config }) =>
meta({
export const generateMetadata: GenerateEditViewMetadata = async ({
collectionConfig,
config,
description: 'API',
globalConfig,
i18n,
}) => {
const entityLabel = collectionConfig
? getTranslation(collectionConfig.labels.singular, i18n)
: globalConfig
? getTranslation(globalConfig.label, i18n)
: ''
const metaTitle = `API - ${entityLabel}`
const description = `API - ${entityLabel}`
return Promise.resolve(
meta({
...(config.admin.meta || {}),
description,
keywords: 'API',
title: 'API',
})
serverURL: config.serverURL,
title: metaTitle,
...(collectionConfig?.admin.meta || {}),
...(globalConfig?.admin.meta || {}),
}),
)
}

View File

@@ -4,8 +4,9 @@ import { meta } from '../../utilities/meta.js'
export const generateAccountMetadata: GenerateViewMetadata = async ({ config, i18n: { t } }) =>
meta({
config,
description: `${t('authentication:accountOfCurrentUser')}`,
keywords: `${t('authentication:account')}`,
serverURL: config.serverURL,
title: t('authentication:account'),
...(config.admin.meta || {}),
})

View File

@@ -7,8 +7,9 @@ export const generateCreateFirstUserMetadata: GenerateViewMetadata = async ({
i18n: { t },
}) =>
meta({
config,
description: t('authentication:createFirstUser'),
keywords: t('general:create'),
serverURL: config.serverURL,
title: t('authentication:createFirstUser'),
...(config.admin.meta || {}),
})

View File

@@ -4,8 +4,13 @@ import { meta } from '../../utilities/meta.js'
export const generateDashboardMetadata: GenerateViewMetadata = async ({ config, i18n: { t } }) =>
meta({
config,
description: `${t('general:dashboard')} Payload`,
keywords: `${t('general:dashboard')}, Payload`,
serverURL: config.serverURL,
title: t('general:dashboard'),
...(config.admin.meta || {}),
openGraph: {
title: t('general:dashboard'),
...(config.admin.meta?.openGraph || {}),
},
})

View File

@@ -15,7 +15,6 @@ export type GenerateEditViewMetadata = (
args: Parameters<GenerateViewMetadata>[0] & {
collectionConfig?: SanitizedCollectionConfig | null
globalConfig?: SanitizedGlobalConfig | null
isEditing: boolean
},
) => Promise<Metadata>
@@ -33,7 +32,8 @@ export const getMetaBySegment: GenerateEditViewMetadata = async ({
const isCollection = segmentOne === 'collections'
const isGlobal = segmentOne === 'globals'
const isEditing = Boolean(isCollection && segments?.length > 2 && segments[2] !== 'create')
const isEditing =
isGlobal || Boolean(isCollection && segments?.length > 2 && segments[2] !== 'create')
if (isCollection) {
// `/:id`

View File

@@ -15,34 +15,40 @@ export const generateMetadata: GenerateEditViewMetadata = async ({
}): Promise<Metadata> => {
const { t } = i18n
let description: string = ''
let title: string = ''
let keywords: string = ''
const entityLabel = collectionConfig
? getTranslation(collectionConfig.labels.singular, i18n)
: globalConfig
? getTranslation(globalConfig.label, i18n)
: ''
if (collectionConfig) {
description = `${isEditing ? t('general:editing') : t('general:creating')} - ${getTranslation(
collectionConfig.labels.singular,
i18n,
)}`
const metaTitle = `${isEditing ? t('general:editing') : t('general:creating')} - ${entityLabel}`
title = `${isEditing ? t('general:editing') : t('general:creating')} - ${getTranslation(
collectionConfig.labels.singular,
i18n,
)}`
const ogTitle = `${isEditing ? t('general:edit') : t('general:edit')} - ${entityLabel}`
keywords = `${getTranslation(collectionConfig.labels.singular, i18n)}, Payload, CMS`
}
const description = `${isEditing ? t('general:editing') : t('general:creating')} - ${entityLabel}`
if (globalConfig) {
description = getTranslation(globalConfig.label, i18n)
keywords = `${getTranslation(globalConfig.label, i18n)}, Payload, CMS`
title = getTranslation(globalConfig.label, i18n)
}
const keywords = `${entityLabel}, Payload, CMS`
const baseOGOverrides = config.admin.meta.openGraph || {}
const entityOGOverrides = collectionConfig
? collectionConfig.admin?.meta?.openGraph
: globalConfig
? globalConfig.admin?.meta?.openGraph
: {}
return meta({
config,
...(config.admin.meta || {}),
description,
keywords,
title,
openGraph: {
title: ogTitle,
...baseOGOverrides,
...entityOGOverrides,
},
...(collectionConfig?.admin.meta || {}),
...(globalConfig?.admin.meta || {}),
serverURL: config.serverURL,
title: metaTitle,
})
}

View File

@@ -7,8 +7,9 @@ export const generateForgotPasswordMetadata: GenerateViewMetadata = async ({
i18n: { t },
}) =>
meta({
config,
description: t('authentication:forgotPassword'),
keywords: t('authentication:forgotPassword'),
title: t('authentication:forgotPassword'),
...(config.admin.meta || {}),
serverURL: config.serverURL,
})

View File

@@ -23,9 +23,11 @@ export const generateListMetadata = async (
}
return meta({
config,
...(config.admin.meta || {}),
description,
keywords,
serverURL: config.serverURL,
title,
...(collectionConfig.admin.meta || {}),
})
}

View File

@@ -1,10 +1,8 @@
import type { Metadata } from 'next'
import { getTranslation } from '@payloadcms/translations'
import type { GenerateEditViewMetadata } from '../Document/getMetaBySegment.js'
import { meta } from '../../utilities/meta.js'
import { generateMetadata as generateDocumentMetadata } from '../Edit/meta.js'
export const generateMetadata: GenerateEditViewMetadata = async ({
collectionConfig,
@@ -12,37 +10,11 @@ export const generateMetadata: GenerateEditViewMetadata = async ({
globalConfig,
i18n,
isEditing,
}): Promise<Metadata> => {
const { t } = i18n
let description: string = ''
let title: string = ''
let keywords: string = ''
if (collectionConfig) {
description = `${isEditing ? t('general:editing') : t('general:creating')} - ${getTranslation(
collectionConfig.labels.singular,
i18n,
)}`
title = `${isEditing ? t('general:editing') : t('general:creating')} - ${getTranslation(
collectionConfig.labels.singular,
i18n,
)}`
keywords = `${getTranslation(collectionConfig.labels.singular, i18n)}, Payload, CMS`
}
if (globalConfig) {
description = getTranslation(globalConfig.label, i18n)
keywords = `${getTranslation(globalConfig.label, i18n)}, Payload, CMS`
title = getTranslation(globalConfig.label, i18n)
}
return meta({
}): Promise<Metadata> =>
generateDocumentMetadata({
collectionConfig,
config,
description,
keywords,
title,
globalConfig,
i18n,
isEditing,
})
}

View File

@@ -4,8 +4,9 @@ import { meta } from '../../utilities/meta.js'
export const generateLoginMetadata: GenerateViewMetadata = async ({ config, i18n: { t } }) =>
meta({
config,
description: `${t('authentication:login')}`,
keywords: `${t('authentication:login')}`,
serverURL: config.serverURL,
title: t('authentication:login'),
...(config.admin.meta || {}),
})

View File

@@ -4,8 +4,8 @@ import { meta } from '../../utilities/meta.js'
export const generateLogoutMetadata: GenerateViewMetadata = async ({ config, i18n: { t } }) =>
meta({
config,
description: `${t('authentication:logoutUser')}`,
keywords: `${t('authentication:logout')}`,
serverURL: config.serverURL,
title: t('authentication:logout'),
})

View File

@@ -4,7 +4,7 @@ import type { SanitizedConfig } from 'payload/types'
import { meta } from '../../utilities/meta.js'
export const generateNotFoundMeta = ({
export const generateNotFoundMeta = async ({
config,
i18n,
}: {
@@ -12,8 +12,8 @@ export const generateNotFoundMeta = ({
i18n: I18n
}): Promise<Metadata> =>
meta({
config,
description: i18n.t('general:pageNotFound'),
keywords: `404 ${i18n.t('general:notFound')}`,
serverURL: config.serverURL,
title: i18n.t('general:notFound'),
})

View File

@@ -9,8 +9,9 @@ export const generateResetPasswordMetadata: GenerateViewMetadata = async ({
i18n: { t },
}): Promise<Metadata> =>
meta({
config,
description: t('authentication:resetPassword'),
keywords: t('authentication:resetPassword'),
serverURL: config.serverURL,
title: t('authentication:resetPassword'),
...(config.admin.meta || {}),
})

View File

@@ -15,6 +15,7 @@ export { generatePageMetadata } from './meta.js'
export type GenerateViewMetadata = (args: {
config: SanitizedConfig
i18n: I18n
isEditing?: boolean
params?: { [key: string]: string | string[] }
}) => Promise<Metadata>

View File

@@ -36,12 +36,6 @@ type Args = {
export const generatePageMetadata = async ({ config: configPromise, params }: Args) => {
const config = await configPromise
let route = config.routes.admin
if (Array.isArray(params.segments)) {
route = route + '/' + params.segments.join('/')
}
const segments = Array.isArray(params.segments) ? params.segments : []
const [segmentOne, segmentTwo] = segments
@@ -101,7 +95,6 @@ export const generatePageMetadata = async ({ config: configPromise, params }: Ar
config,
globalConfig,
i18n,
isEditing: false,
params,
})
}
@@ -118,16 +111,19 @@ export const generatePageMetadata = async ({ config: configPromise, params }: Ar
// --> /collections/:collectionSlug/:id/versions
// --> /collections/:collectionSlug/:id/versions/:version
// --> /collections/:collectionSlug/:id/api
const isEditing = ['api', 'create', 'preview', 'versions'].includes(segmentTwo)
meta = await generateDocumentMetadata({ collectionConfig, config, i18n, isEditing, params })
meta = await generateDocumentMetadata({ collectionConfig, config, i18n, params })
} else if (isGlobal) {
// Custom Views
// --> /globals/:globalSlug/versions
// --> /globals/:globalSlug/versions/:version
// --> /globals/:globalSlug/preview
// --> /globals/:globalSlug/api
const isEditing = ['api', 'create', 'preview', 'versions'].includes(segmentTwo)
meta = await generateDocumentMetadata({ config, globalConfig, i18n, isEditing, params })
meta = await generateDocumentMetadata({
config,
globalConfig,
i18n,
params,
})
}
break
}

View File

@@ -4,8 +4,9 @@ import { meta } from '../../utilities/meta.js'
export const generateUnauthorizedMetadata: GenerateViewMetadata = async ({ config, i18n: { t } }) =>
meta({
config,
description: t('error:unauthorized'),
keywords: t('error:unauthorized'),
serverURL: config.serverURL,
title: t('error:unauthorized'),
...(config.admin.meta || {}),
})

View File

@@ -4,8 +4,9 @@ import { meta } from '../../utilities/meta.js'
export const generateVerifyMetadata: GenerateViewMetadata = async ({ config, i18n: { t } }) =>
meta({
config,
description: t('authentication:verifyUser'),
keywords: t('authentication:verify'),
serverURL: config.serverURL,
title: t('authentication:verify'),
...(config.admin.meta || {}),
})

View File

@@ -40,9 +40,12 @@ export const generateMetadata: GenerateEditViewMetadata = async ({
}
return meta({
config,
...(config.admin.meta || {}),
description,
keywords,
serverURL: config.serverURL,
title,
...(collectionConfig?.admin.meta || {}),
...(globalConfig?.admin.meta || {}),
})
}

View File

@@ -14,6 +14,12 @@ export const generateMetadata: GenerateEditViewMetadata = async ({
}): Promise<Metadata> => {
const { t } = i18n
const entityLabel = collectionConfig
? getTranslation(collectionConfig.labels.singular, i18n)
: globalConfig
? getTranslation(globalConfig.label, i18n)
: ''
let title: string = ''
let description: string = ''
const keywords: string = ''
@@ -23,7 +29,7 @@ export const generateMetadata: GenerateEditViewMetadata = async ({
if (collectionConfig) {
const useAsTitle = collectionConfig?.admin?.useAsTitle || 'id'
const titleFromData = data?.[useAsTitle]
title = `${t('version:versions')}${titleFromData ? ` - ${titleFromData}` : ''} - ${getTranslation(collectionConfig.labels.plural, i18n)}`
title = `${t('version:versions')}${titleFromData ? ` - ${titleFromData}` : ''} - ${entityLabel}`
description = t('version:viewingVersions', {
documentTitle: data?.[useAsTitle],
entitySlug: collectionConfig.slug,
@@ -31,14 +37,17 @@ export const generateMetadata: GenerateEditViewMetadata = async ({
}
if (globalConfig) {
title = `${t('version:versions')} - ${getTranslation(globalConfig.label, i18n)}`
title = `${t('version:versions')} - ${entityLabel}`
description = t('version:viewingVersionsGlobal', { entitySlug: globalConfig.slug })
}
return meta({
config,
...(config.admin.meta || {}),
description,
keywords,
serverURL: config.serverURL,
title,
...(collectionConfig?.admin.meta || {}),
...(globalConfig?.admin.meta || {}),
})
}

View File

@@ -147,7 +147,6 @@
"get-port": "5.1.1",
"graphql-http": "^1.22.0",
"mini-css-extract-plugin": "1.6.2",
"next": "^14.3.0-canary.7",
"nodemon": "3.0.3",
"object.assign": "4.1.4",
"object.entries": "1.1.6",

View File

@@ -6,6 +6,7 @@ import {
customViewSchema,
livePreviewSchema,
} from '../../config/shared/componentSchema.js'
import { openGraphSchema } from '../../config/shared/openGraphSchema.js'
const strategyBaseSchema = joi.object().keys({
logout: joi.boolean(),
@@ -70,6 +71,10 @@ const collectionSchema = joi.object().keys({
hideAPIURL: joi.bool(),
listSearchableFields: joi.array().items(joi.string()),
livePreview: joi.object(livePreviewSchema),
meta: joi.object({
description: joi.string(),
openGraph: openGraphSchema,
}),
pagination: joi.object({
defaultLimit: joi.number(),
limits: joi.array().items(joi.number()),

View File

@@ -18,6 +18,7 @@ import type {
GeneratePreviewURL,
LabelFunction,
LivePreviewConfig,
OpenGraphConfig,
} from '../../config/types.js'
import type { DBIdentifierName } from '../../database/types.js'
import type { Field } from '../../fields/config/types.js'
@@ -282,6 +283,10 @@ export type CollectionAdminOptions = {
* Live preview options
*/
livePreview?: LivePreviewConfig
meta?: {
description?: string
openGraph?: OpenGraphConfig
}
pagination?: {
defaultLimit?: number
limits?: number[]

View File

@@ -8,6 +8,7 @@ export const defaults: Omit<Config, 'db' | 'editor' | 'secret'> = {
dateFormat: 'MMMM do yyyy, h:mm a',
disable: false,
meta: {
defaultOGImageType: 'dynamic',
titleSuffix: '- Payload',
},
routes: {

View File

@@ -2,6 +2,7 @@ import joi from 'joi'
import { adminViewSchema } from './shared/adminViewSchema.js'
import { componentSchema, livePreviewSchema } from './shared/componentSchema.js'
import { openGraphSchema } from './shared/openGraphSchema.js'
const component = joi.alternatives().try(joi.object().unknown(), joi.func())
@@ -66,14 +67,10 @@ export default joi.object({
globals: joi.array().items(joi.string()),
}),
meta: joi.object().keys({
icons: joi
.alternatives()
.try(
joi.array().items(joi.alternatives().try(joi.string(), joi.object())),
joi.object(),
joi.string().allow(null),
),
ogImage: joi.string(),
defaultOGImageType: joi.string().valid('off', 'dynamic', 'static'),
description: joi.string(),
icons: joi.array().items(joi.object()),
openGraph: openGraphSchema,
titleSuffix: joi.string(),
}),
routes: joi.object({

View File

@@ -0,0 +1,16 @@
import joi from 'joi'
const ogImageObj = joi.object({
type: joi.string(),
alt: joi.string(),
height: joi.alternatives().try(joi.string(), joi.number()),
url: joi.string(),
width: joi.alternatives().try(joi.string(), joi.number()),
})
export const openGraphSchema = joi.object({
description: joi.string(),
images: joi.alternatives().try(joi.array().items(joi.string()), joi.array().items(ogImageObj)),
title: joi.string(),
url: joi.string(),
})

View File

@@ -72,6 +72,77 @@ export type LivePreviewConfig = {
| string
}
export type OGImageConfig = {
alt?: string
height?: number | string
type?: string
url: string
width?: number | string
}
export type OpenGraphConfig = {
description?: string
images?: OGImageConfig | OGImageConfig[]
siteName?: string
title?: string
}
export type IconConfig = {
color?: string
/**
* @see https://developer.mozilla.org/docs/Web/API/HTMLImageElement/fetchPriority
*/
fetchPriority?: 'auto' | 'high' | 'low'
media?: string
/** defaults to rel="icon" */
rel?: string
sizes?: string
type?: string
url: string
}
export type MetaConfig = {
/**
* When `static`, a pre-made image will be used for all pages.
* When `dynamic`, a unique image will be generated for each page based on page content and given overrides.
* When `off`, no Open Graph images will be generated and the `/api/og` endpoint will be disabled. You can still provide custom images using the `openGraph.images` property.
* @default 'dynamic'
*/
defaultOGImageType?: 'dynamic' | 'off' | 'static'
/**
* Overrides the auto-generated <meta name="description"> of admin pages
* @example `"This is my custom CMS built with Payload."`
*/
description?: string
/**
* Icons to be rendered by devices and browsers.
*
* For example browser tabs, phone home screens, and search engine results.
*/
icons?: IconConfig
/**
* Overrides the auto-generated <meta name="keywords"> of admin pages
* @example `"CMS, Payload, Custom"`
*/
keywords?: string
/**
* Metadata to be rendered as `og` meta tags in the head of the Admin Panel.
*
* For example when sharing the Admin Panel on social media or through messaging services.
*/
openGraph?: OpenGraphConfig
/**
* Overrides the auto-generated <title> of admin pages
* @example `"My Admin Panel"`
*/
title?: string
/**
* String to append to the auto-generated <title> of admin pages
* @example `" - Custom CMS"`
*/
titleSuffix?: string
}
export type ServerOnlyLivePreviewProperties = keyof Pick<LivePreviewConfig, 'url'>
type GeneratePreviewURLOptions = {
@@ -477,26 +548,7 @@ export type Config = {
globals?: string[]
}
/** Base meta data to use for the Admin Panel. Included properties are titleSuffix, ogImage, and favicon. */
meta?: {
/**
* An array of Next.js metadata objects that represent icons to be used by devices and browsers.
*
* For example browser tabs, phone home screens, and search engine results.
* @reference https://nextjs.org/docs/app/api-reference/functions/generate-metadata#icons
*/
icons?: NextMetadata['icons']
/**
* Public path to an image
*
* This image may be displayed as preview when the link is shared on social media
*/
ogImage?: string
/**
* String to append to the <title> of admin pages
* @example `" - My Brand"`
*/
titleSuffix?: string
}
meta?: MetaConfig
routes?: {
/** The route for the account page. */
account?: string

View File

@@ -6,6 +6,7 @@ import {
customViewSchema,
livePreviewSchema,
} from '../../config/shared/componentSchema.js'
import { openGraphSchema } from '../../config/shared/openGraphSchema.js'
const globalSchema = joi
.object()
@@ -50,6 +51,10 @@ const globalSchema = joi
hidden: joi.alternatives().try(joi.boolean(), joi.func()),
hideAPIURL: joi.boolean(),
livePreview: joi.object(livePreviewSchema),
meta: joi.object({
description: joi.string(),
openGraph: openGraphSchema,
}),
preview: joi.func(),
}),
custom: joi.object().pattern(joi.string(), joi.any()),

View File

@@ -15,6 +15,7 @@ import type {
EntityDescriptionComponent,
GeneratePreviewURL,
LivePreviewConfig,
OpenGraphConfig,
} from '../../config/types.js'
import type { DBIdentifierName } from '../../database/types.js'
import type { Field } from '../../fields/config/types.js'
@@ -129,6 +130,10 @@ export type GlobalAdminOptions = {
* Live preview options
*/
livePreview?: LivePreviewConfig
meta?: {
description?: string
openGraph?: OpenGraphConfig
}
/**
* Function to generate custom preview URL
*/

View File

@@ -1,4 +1,4 @@
export { default as payloadOgImage } from '../assets/og-image.png'
export { default as payloadFavicon } from '../assets/payload-favicon.svg'
export { default as payloadFaviconDark } from '../assets/payload-favicon-dark.png'
export { default as payloadFaviconLight } from '../assets/payload-favicon-light.png'
export { default as payloadFavicon } from './payload-favicon.svg'
export { default as payloadFaviconDark } from './payload-favicon-dark.png'
export { default as payloadFaviconLight } from './payload-favicon-light.png'
export { default as staticOGImage } from './static-og-image.png'

View File

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

View File

@@ -17,6 +17,8 @@
// Use a pseudo element for the accessability so that it doesn't take up DOM space
// Also because the parent element has `overflow: hidden` which would clip an outline
&__home {
width: 18px;
height: 18px;
position: relative;
&:focus-visible {
@@ -63,6 +65,15 @@
white-space: nowrap;
}
@include mid-break {
.step-nav {
&__home {
width: 16px;
height: 16px;
}
}
}
@include small-break {
gap: calc(var(--base) / 4);
}

View File

@@ -2,9 +2,10 @@
import type { LabelFunction } from 'payload/config'
import { getTranslation } from '@payloadcms/translations'
import { useComponentMap } from '@payloadcms/ui/providers/ComponentMap'
import React, { Fragment, createContext, useContext, useState } from 'react'
import { Icon } from '../../graphics/Icon/index.js'
import { PayloadIcon } from '../../graphics/Icon/index.js'
import { useConfig } from '../../providers/Config/index.js'
import { useTranslation } from '../../providers/Translation/index.js'
import './index.scss'
@@ -56,6 +57,12 @@ const StepNav: React.FC<{
routes: { admin },
} = config
const { componentMap } = useComponentMap()
const { t } = useTranslation()
const Icon = componentMap?.Icon || <PayloadIcon />
const LinkElement = Link || 'a'
return (
@@ -63,7 +70,7 @@ const StepNav: React.FC<{
{stepNav.length > 0 ? (
<nav className={[baseClass, className].filter(Boolean).join(' ')}>
<LinkElement className={`${baseClass}__home`} href={admin} tabIndex={0}>
<Icon />
<span title={t('general:dashboard')}>{Icon}</span>
</LinkElement>
<span>/</span>
{stepNav.map((item, i) => {
@@ -92,7 +99,9 @@ const StepNav: React.FC<{
</nav>
) : (
<div className={[baseClass, className].filter(Boolean).join(' ')}>
<Icon />
<div className={`${baseClass}__home`}>
<span title={t('general:dashboard')}>{Icon}</span>
</div>
</div>
)}
</Fragment>

View File

@@ -1,31 +1,11 @@
import React, { Fragment } from 'react'
import React from 'react'
import { useComponentMap } from '../../providers/ComponentMap/index.js'
import { useTranslation } from '../../providers/Translation/index.js'
const css = `
.graphic-icon {
width: 18px;
height: 18px;
}
.graphic-icon path {
fill: var(--theme-elevation-1000);
}
@media (max-width: 768px) {
.graphic-icon {
width: 16px;
height: 16px;
}
}
`
const PayloadIcon: React.FC = () => {
const { t } = useTranslation()
export const PayloadIcon: React.FC<{
fill?: string
}> = ({ fill: fillFromProps }) => {
const fill = fillFromProps || 'var(--theme-elevation-1000)'
return (
<span title={t('general:dashboard')}>
<svg
className="graphic-icon"
height="100%"
@@ -33,22 +13,11 @@ const PayloadIcon: React.FC = () => {
width="100%"
xmlns="http://www.w3.org/2000/svg"
>
<style>{css}</style>
<path d="M11.5293 0L23 6.90096V19.9978L14.3608 25V11.9032L2.88452 5.00777L11.5293 0Z" />
<path d="M10.6559 24.2727V14.0518L2 19.0651L10.6559 24.2727Z" />
<path
d="M11.5293 0L23 6.90096V19.9978L14.3608 25V11.9032L2.88452 5.00777L11.5293 0Z"
fill={fill}
/>
<path d="M10.6559 24.2727V14.0518L2 19.0651L10.6559 24.2727Z" fill={fill} />
</svg>
</span>
)
}
export const Icon: React.FC = () => {
const {
componentMap: { Icon: CustomIcon },
} = useComponentMap()
if (CustomIcon) {
return <Fragment>{CustomIcon}</Fragment>
}
return <PayloadIcon />
}

3
pnpm-lock.yaml generated
View File

@@ -912,9 +912,6 @@ importers:
mini-css-extract-plugin:
specifier: 1.6.2
version: 1.6.2(webpack@5.91.0)
next:
specifier: ^14.3.0-canary.7
version: 14.3.0-canary.7(@babel/core@7.24.4)(@playwright/test@1.43.0)(react-dom@18.2.0)(react@18.2.0)(sass@1.74.1)
nodemon:
specifier: 3.0.3
version: 3.0.3

View File

@@ -1,15 +1,15 @@
import path from 'path'
import { getFileByPath } from 'payload/uploads'
import { fileURLToPath } from 'url'
// import path from 'path'
// import { getFileByPath } from 'payload/uploads'
// import { fileURLToPath } from 'url'
import { buildConfigWithDefaults } from '../buildConfigWithDefaults.js'
import { devUser } from '../credentials.js'
import { MediaCollection } from './collections/Media/index.js'
// import { MediaCollection } from './collections/Media/index.js'
import { PostsCollection, postsSlug } from './collections/Posts/index.js'
import { MenuGlobal } from './globals/Menu/index.js'
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
// const filename = fileURLToPath(import.meta.url)
// const dirname = path.dirname(filename)
export default buildConfigWithDefaults({
// ...extend config here

View File

@@ -18,6 +18,13 @@ export const Posts: CollectionConfig = {
listSearchableFields: ['id', 'title', 'description', 'number'],
preview: () => 'https://payloadcms.com',
useAsTitle: 'title',
meta: {
description: 'This is a custom meta description for posts',
openGraph: {
title: 'This is a custom OG title for posts',
description: 'This is a custom OG description for posts',
},
},
},
fields: [
{

View File

@@ -1,5 +1,4 @@
import { buildConfigWithDefaults } from '../buildConfigWithDefaults.js'
import { devUser } from '../credentials.js'
import { CustomIdRow } from './collections/CustomIdRow.js'
import { CustomIdTab } from './collections/CustomIdTab.js'
import { CustomViews1 } from './collections/CustomViews1.js'
@@ -42,6 +41,7 @@ import {
customParamViewPath,
customViewPath,
} from './shared.js'
export default buildConfigWithDefaults({
admin: {
components: {
@@ -83,6 +83,12 @@ export default buildConfigWithDefaults({
},
routes: customAdminRoutes,
meta: {
titleSuffix: '- Custom CMS',
description: 'This is a custom meta description',
openGraph: {
title: 'This is a custom OG title',
description: 'This is a custom OG description',
},
icons: [
{
type: 'image/png',

View File

@@ -15,7 +15,6 @@ import {
exactText,
getAdminRoutes,
initPageConsoleErrorCatch,
login,
openDocControls,
openDocDrawer,
openNav,
@@ -117,7 +116,12 @@ describe('admin', () => {
})
describe('metadata', () => {
test('should set Payload favicons', async () => {
test('should render custom page title suffix', async () => {
await page.goto(`${serverURL}/admin`)
await expect(page.title()).resolves.toMatch(/- Custom CMS$/)
})
test('should render payload favicons', async () => {
await page.goto(postsUrl.admin)
const favicons = page.locator('link[rel="icon"]')
await expect(favicons).toHaveCount(4)
@@ -130,7 +134,25 @@ describe('admin', () => {
)
})
test('should inject custom favicons', async () => {
test('should render custom meta description from root config', async () => {
await page.goto(`${serverURL}/admin`)
await expect(page.locator('meta[name="description"]')).toHaveAttribute(
'content',
/This is a custom meta description/,
)
})
test('should render custom meta description from collection config', async () => {
await page.goto(postsUrl.collection(postsCollectionSlug))
await page.locator('.collection-list .table a').first().click()
await expect(page.locator('meta[name="description"]')).toHaveAttribute(
'content',
/This is a custom meta description for posts/,
)
})
test('should render custom favicons', async () => {
await page.goto(postsUrl.admin)
const favicons = page.locator('link[rel="icon"]')
await expect(favicons).toHaveCount(4)
@@ -138,6 +160,65 @@ describe('admin', () => {
await expect(favicons.nth(3)).toHaveAttribute('media', '(prefers-color-scheme: dark)')
await expect(favicons.nth(3)).toHaveAttribute('href', /\/custom-favicon-light\.[a-z\d]+\.png/)
})
test('should render custom og:title from root config', async () => {
await page.goto(`${serverURL}/admin`)
await expect(page.locator('meta[property="og:title"]')).toHaveAttribute(
'content',
/This is a custom OG title/,
)
})
test('should render custom og:description from root config', async () => {
await page.goto(`${serverURL}/admin`)
await expect(page.locator('meta[property="og:description"]')).toHaveAttribute(
'content',
/This is a custom OG description/,
)
})
test('should render custom og:title from collection config', async () => {
await page.goto(postsUrl.collection(postsCollectionSlug))
await page.locator('.collection-list .table a').first().click()
await expect(page.locator('meta[property="og:title"]')).toHaveAttribute(
'content',
/This is a custom OG title for posts/,
)
})
test('should render custom og:description from collection config', async () => {
await page.goto(postsUrl.collection(postsCollectionSlug))
await page.locator('.collection-list .table a').first().click()
await expect(page.locator('meta[property="og:description"]')).toHaveAttribute(
'content',
/This is a custom OG description for posts/,
)
})
test('should render og:image with dynamic URL', async () => {
await page.goto(postsUrl.admin)
const encodedOGDescription = encodeURIComponent('This is a custom OG description')
const encodedOGTitle = encodeURIComponent('This is a custom OG title')
await expect(page.locator('meta[property="og:image"]')).toHaveAttribute(
'content',
new RegExp(`/api/og\\?description=${encodedOGDescription}&title=${encodedOGTitle}`),
)
})
test('should render twitter:image with dynamic URL', async () => {
await page.goto(postsUrl.admin)
const encodedOGDescription = encodeURIComponent('This is a custom OG description')
const encodedOGTitle = encodeURIComponent('This is a custom OG title')
await expect(page.locator('meta[name="twitter:image"]')).toHaveAttribute(
'content',
new RegExp(`/api/og\\?description=${encodedOGDescription}&title=${encodedOGTitle}`),
)
})
})
describe('routing', () => {