diff --git a/docs/admin/overview.mdx b/docs/admin/overview.mdx index a053f7420..4d649b578 100644 --- a/docs/admin/overview.mdx +++ b/docs/admin/overview.mdx @@ -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). | diff --git a/docs/configuration/collections.mdx b/docs/configuration/collections.mdx index b532057b7..451d99292 100644 --- a/docs/configuration/collections.mdx +++ b/docs/configuration/collections.mdx @@ -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) | diff --git a/docs/configuration/globals.mdx b/docs/configuration/globals.mdx index a4dad310f..08483bd93 100644 --- a/docs/configuration/globals.mdx +++ b/docs/configuration/globals.mdx @@ -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 diff --git a/packages/next/src/routes/rest/index.ts b/packages/next/src/routes/rest/index.ts index 668ca3bfd..9598a893e 100644 --- a/packages/next/src/routes/rest/index.ts +++ b/packages/next/src/routes/rest/index.ts @@ -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, diff --git a/packages/next/src/routes/rest/og/image.tsx b/packages/next/src/routes/rest/og/image.tsx new file mode 100644 index 000000000..619c6cb8c --- /dev/null +++ b/packages/next/src/routes/rest/og/image.tsx @@ -0,0 +1,89 @@ +import React from 'react' + +export const OGImage: React.FC<{ + Icon: React.ComponentType + description?: string + fontFamily?: string + leader?: string + title?: string +}> = ({ Icon, description, fontFamily = 'Arial, sans-serif', leader, title }) => { + return ( +
+
+ {leader && ( +
+ {leader} +
+ )} +

+ {title} +

+ {description && ( +

+ {description} +

+ )} +
+
+ +
+
+ ) +} diff --git a/packages/next/src/routes/rest/og/index.tsx b/packages/next/src/routes/rest/og/index.tsx new file mode 100644 index 000000000..32a64aff3 --- /dev/null +++ b/packages/next/src/routes/rest/og/index.tsx @@ -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( + ( + + ), + { + ...(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 }) + } +} diff --git a/packages/next/src/routes/rest/og/roboto-regular.woff b/packages/next/src/routes/rest/og/roboto-regular.woff new file mode 100644 index 000000000..6ff6afd8c Binary files /dev/null and b/packages/next/src/routes/rest/og/roboto-regular.woff differ diff --git a/packages/next/src/utilities/meta.ts b/packages/next/src/utilities/meta.ts index 6034120e0..b917e1e24 100644 --- a/packages/next/src/utilities/meta.ts +++ b/packages/next/src/utilities/meta.ts @@ -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 => { - 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 => { + 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, }) } diff --git a/packages/next/src/views/API/meta.ts b/packages/next/src/views/API/meta.ts index 543f97075..93505d707 100644 --- a/packages/next/src/views/API/meta.ts +++ b/packages/next/src/views/API/meta.ts @@ -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({ - config, - description: 'API', - keywords: 'API', - title: 'API', - }) +export const generateMetadata: GenerateEditViewMetadata = async ({ + collectionConfig, + config, + 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', + serverURL: config.serverURL, + title: metaTitle, + ...(collectionConfig?.admin.meta || {}), + ...(globalConfig?.admin.meta || {}), + }), + ) +} diff --git a/packages/next/src/views/Account/meta.ts b/packages/next/src/views/Account/meta.ts index 89653ff7a..459ef31c9 100644 --- a/packages/next/src/views/Account/meta.ts +++ b/packages/next/src/views/Account/meta.ts @@ -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 || {}), }) diff --git a/packages/next/src/views/CreateFirstUser/meta.ts b/packages/next/src/views/CreateFirstUser/meta.ts index 7787f0010..09d150291 100644 --- a/packages/next/src/views/CreateFirstUser/meta.ts +++ b/packages/next/src/views/CreateFirstUser/meta.ts @@ -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 || {}), }) diff --git a/packages/next/src/views/Dashboard/meta.ts b/packages/next/src/views/Dashboard/meta.ts index 3c9b6a531..518c8bf0c 100644 --- a/packages/next/src/views/Dashboard/meta.ts +++ b/packages/next/src/views/Dashboard/meta.ts @@ -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 || {}), + }, }) diff --git a/packages/next/src/views/Document/getMetaBySegment.tsx b/packages/next/src/views/Document/getMetaBySegment.tsx index 34fa17f49..2029e7fe0 100644 --- a/packages/next/src/views/Document/getMetaBySegment.tsx +++ b/packages/next/src/views/Document/getMetaBySegment.tsx @@ -15,7 +15,6 @@ export type GenerateEditViewMetadata = ( args: Parameters[0] & { collectionConfig?: SanitizedCollectionConfig | null globalConfig?: SanitizedGlobalConfig | null - isEditing: boolean }, ) => Promise @@ -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` diff --git a/packages/next/src/views/Edit/meta.ts b/packages/next/src/views/Edit/meta.ts index dc3f74277..ab58ac0a7 100644 --- a/packages/next/src/views/Edit/meta.ts +++ b/packages/next/src/views/Edit/meta.ts @@ -15,34 +15,40 @@ export const generateMetadata: GenerateEditViewMetadata = async ({ }): Promise => { 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, }) } diff --git a/packages/next/src/views/ForgotPassword/meta.ts b/packages/next/src/views/ForgotPassword/meta.ts index 55d57c194..3e6f72bc2 100644 --- a/packages/next/src/views/ForgotPassword/meta.ts +++ b/packages/next/src/views/ForgotPassword/meta.ts @@ -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, }) diff --git a/packages/next/src/views/List/meta.ts b/packages/next/src/views/List/meta.ts index 58a74e6d8..1bcef6ba0 100644 --- a/packages/next/src/views/List/meta.ts +++ b/packages/next/src/views/List/meta.ts @@ -23,9 +23,11 @@ export const generateListMetadata = async ( } return meta({ - config, + ...(config.admin.meta || {}), description, keywords, + serverURL: config.serverURL, title, + ...(collectionConfig.admin.meta || {}), }) } diff --git a/packages/next/src/views/LivePreview/meta.ts b/packages/next/src/views/LivePreview/meta.ts index dc3f74277..26deaf311 100644 --- a/packages/next/src/views/LivePreview/meta.ts +++ b/packages/next/src/views/LivePreview/meta.ts @@ -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 => { - 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 => + generateDocumentMetadata({ + collectionConfig, config, - description, - keywords, - title, + globalConfig, + i18n, + isEditing, }) -} diff --git a/packages/next/src/views/Login/meta.ts b/packages/next/src/views/Login/meta.ts index 68eba50dd..a05263793 100644 --- a/packages/next/src/views/Login/meta.ts +++ b/packages/next/src/views/Login/meta.ts @@ -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 || {}), }) diff --git a/packages/next/src/views/Logout/meta.ts b/packages/next/src/views/Logout/meta.ts index d615aba74..39e623d6d 100644 --- a/packages/next/src/views/Logout/meta.ts +++ b/packages/next/src/views/Logout/meta.ts @@ -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'), }) diff --git a/packages/next/src/views/NotFound/meta.ts b/packages/next/src/views/NotFound/meta.ts index 32c206941..fd9b39dc6 100644 --- a/packages/next/src/views/NotFound/meta.ts +++ b/packages/next/src/views/NotFound/meta.ts @@ -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 => meta({ - config, description: i18n.t('general:pageNotFound'), keywords: `404 ${i18n.t('general:notFound')}`, + serverURL: config.serverURL, title: i18n.t('general:notFound'), }) diff --git a/packages/next/src/views/ResetPassword/meta.ts b/packages/next/src/views/ResetPassword/meta.ts index 45ff7e410..1fed0922c 100644 --- a/packages/next/src/views/ResetPassword/meta.ts +++ b/packages/next/src/views/ResetPassword/meta.ts @@ -9,8 +9,9 @@ export const generateResetPasswordMetadata: GenerateViewMetadata = async ({ i18n: { t }, }): Promise => meta({ - config, description: t('authentication:resetPassword'), keywords: t('authentication:resetPassword'), + serverURL: config.serverURL, title: t('authentication:resetPassword'), + ...(config.admin.meta || {}), }) diff --git a/packages/next/src/views/Root/index.tsx b/packages/next/src/views/Root/index.tsx index 049ec4ea3..37eccde35 100644 --- a/packages/next/src/views/Root/index.tsx +++ b/packages/next/src/views/Root/index.tsx @@ -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 diff --git a/packages/next/src/views/Root/meta.ts b/packages/next/src/views/Root/meta.ts index 496afe871..6aa360827 100644 --- a/packages/next/src/views/Root/meta.ts +++ b/packages/next/src/views/Root/meta.ts @@ -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 } diff --git a/packages/next/src/views/Unauthorized/meta.ts b/packages/next/src/views/Unauthorized/meta.ts index 42dd4d333..9dc8b8e21 100644 --- a/packages/next/src/views/Unauthorized/meta.ts +++ b/packages/next/src/views/Unauthorized/meta.ts @@ -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 || {}), }) diff --git a/packages/next/src/views/Verify/meta.ts b/packages/next/src/views/Verify/meta.ts index 56656adf8..75485c7c7 100644 --- a/packages/next/src/views/Verify/meta.ts +++ b/packages/next/src/views/Verify/meta.ts @@ -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 || {}), }) diff --git a/packages/next/src/views/Version/meta.ts b/packages/next/src/views/Version/meta.ts index 9190847be..04739bd2e 100644 --- a/packages/next/src/views/Version/meta.ts +++ b/packages/next/src/views/Version/meta.ts @@ -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 || {}), }) } diff --git a/packages/next/src/views/Versions/meta.ts b/packages/next/src/views/Versions/meta.ts index 59d680f61..4ba4090bd 100644 --- a/packages/next/src/views/Versions/meta.ts +++ b/packages/next/src/views/Versions/meta.ts @@ -14,6 +14,12 @@ export const generateMetadata: GenerateEditViewMetadata = async ({ }): Promise => { 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 || {}), }) } diff --git a/packages/payload/package.json b/packages/payload/package.json index 50fa46cab..b2c7358d6 100644 --- a/packages/payload/package.json +++ b/packages/payload/package.json @@ -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", diff --git a/packages/payload/src/collections/config/schema.ts b/packages/payload/src/collections/config/schema.ts index 98e92ca24..709ab0a7b 100644 --- a/packages/payload/src/collections/config/schema.ts +++ b/packages/payload/src/collections/config/schema.ts @@ -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()), diff --git a/packages/payload/src/collections/config/types.ts b/packages/payload/src/collections/config/types.ts index c839991b0..ee4e29e12 100644 --- a/packages/payload/src/collections/config/types.ts +++ b/packages/payload/src/collections/config/types.ts @@ -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[] diff --git a/packages/payload/src/config/defaults.ts b/packages/payload/src/config/defaults.ts index 8e62dacbb..e976652e4 100644 --- a/packages/payload/src/config/defaults.ts +++ b/packages/payload/src/config/defaults.ts @@ -8,6 +8,7 @@ export const defaults: Omit = { dateFormat: 'MMMM do yyyy, h:mm a', disable: false, meta: { + defaultOGImageType: 'dynamic', titleSuffix: '- Payload', }, routes: { diff --git a/packages/payload/src/config/schema.ts b/packages/payload/src/config/schema.ts index eedb5daa1..fecda834f 100644 --- a/packages/payload/src/config/schema.ts +++ b/packages/payload/src/config/schema.ts @@ -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({ diff --git a/packages/payload/src/config/shared/openGraphSchema.ts b/packages/payload/src/config/shared/openGraphSchema.ts new file mode 100644 index 000000000..37679556d --- /dev/null +++ b/packages/payload/src/config/shared/openGraphSchema.ts @@ -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(), +}) diff --git a/packages/payload/src/config/types.ts b/packages/payload/src/config/types.ts index 94dc4aa87..0c76c068e 100644 --- a/packages/payload/src/config/types.ts +++ b/packages/payload/src/config/types.ts @@ -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 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 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 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 diff --git a/packages/payload/src/globals/config/schema.ts b/packages/payload/src/globals/config/schema.ts index fcf158e86..9ef0db872 100644 --- a/packages/payload/src/globals/config/schema.ts +++ b/packages/payload/src/globals/config/schema.ts @@ -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()), diff --git a/packages/payload/src/globals/config/types.ts b/packages/payload/src/globals/config/types.ts index 06e9c7f74..4affebbd6 100644 --- a/packages/payload/src/globals/config/types.ts +++ b/packages/payload/src/globals/config/types.ts @@ -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 */ diff --git a/packages/ui/src/assets/index.ts b/packages/ui/src/assets/index.ts index accc985b4..eb0e78757 100644 --- a/packages/ui/src/assets/index.ts +++ b/packages/ui/src/assets/index.ts @@ -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' diff --git a/packages/ui/src/assets/og-image.png b/packages/ui/src/assets/static-og-image.png similarity index 100% rename from packages/ui/src/assets/og-image.png rename to packages/ui/src/assets/static-og-image.png diff --git a/packages/ui/src/elements/StepNav/index.scss b/packages/ui/src/elements/StepNav/index.scss index 72c1bfaf2..dd3e93c8b 100644 --- a/packages/ui/src/elements/StepNav/index.scss +++ b/packages/ui/src/elements/StepNav/index.scss @@ -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); } diff --git a/packages/ui/src/elements/StepNav/index.tsx b/packages/ui/src/elements/StepNav/index.tsx index 645fbee4d..7b73829c1 100644 --- a/packages/ui/src/elements/StepNav/index.tsx +++ b/packages/ui/src/elements/StepNav/index.tsx @@ -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> diff --git a/packages/ui/src/graphics/Icon/index.tsx b/packages/ui/src/graphics/Icon/index.tsx index c3dd4cecb..303308396 100644 --- a/packages/ui/src/graphics/Icon/index.tsx +++ b/packages/ui/src/graphics/Icon/index.tsx @@ -1,54 +1,23 @@ -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%" - viewBox="0 0 25 25" - 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" /> - </svg> - </span> + <svg + className="graphic-icon" + height="100%" + viewBox="0 0 25 25" + width="100%" + xmlns="http://www.w3.org/2000/svg" + > + <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> ) } - -export const Icon: React.FC = () => { - const { - componentMap: { Icon: CustomIcon }, - } = useComponentMap() - - if (CustomIcon) { - return <Fragment>{CustomIcon}</Fragment> - } - - return <PayloadIcon /> -} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a0a195d03..7f08ef007 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/test/_community/config.ts b/test/_community/config.ts index 2824dd76d..1f1927f4e 100644 --- a/test/_community/config.ts +++ b/test/_community/config.ts @@ -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 diff --git a/test/admin/collections/Posts.ts b/test/admin/collections/Posts.ts index 9fa3f8cd1..fdf8ac4fe 100644 --- a/test/admin/collections/Posts.ts +++ b/test/admin/collections/Posts.ts @@ -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: [ { diff --git a/test/admin/config.ts b/test/admin/config.ts index a2d871829..21ac1e220 100644 --- a/test/admin/config.ts +++ b/test/admin/config.ts @@ -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', diff --git a/test/admin/e2e.spec.ts b/test/admin/e2e.spec.ts index af9dcd26f..9c9ff43c2 100644 --- a/test/admin/e2e.spec.ts +++ b/test/admin/e2e.spec.ts @@ -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', () => {