next.js/packages/next/src/lib/metadata/resolvers/resolve-opengraph.ts
resolve-opengraph.ts260 lines8.3 KB
import type { ResolvedMetadataWithURLs } from '../types/metadata-interface'
import type {
  OpenGraphType,
  OpenGraph,
  ResolvedOpenGraph,
} from '../types/opengraph-types'
import type {
  FieldResolverExtraArgs,
  AsyncFieldResolverExtraArgs,
  MetadataContext,
} from '../types/resolvers'
import type { ResolvedTwitterMetadata, Twitter } from '../types/twitter-types'
import { resolveArray, resolveAsArrayOrUndefined } from '../generate/utils'
import {
  getSocialImageMetadataBaseFallback,
  isStringOrURL,
  resolveUrl,
  resolveAbsoluteUrlWithPathname,
  type MetadataBaseURL,
} from './resolve-url'
import { resolveTitle } from './resolve-title'
import { isFullStringUrl } from '../../url'
import { warnOnce } from '../../../build/output/log'

type FlattenArray<T> = T extends (infer U)[] ? U : T

const OgTypeFields = {
  article: ['authors', 'tags'],
  song: ['albums', 'musicians'],
  playlist: ['albums', 'musicians'],
  radio: ['creators'],
  video: ['actors', 'directors', 'writers', 'tags'],
  basic: [
    'emails',
    'phoneNumbers',
    'faxNumbers',
    'alternateLocale',
    'audio',
    'videos',
  ],
} as const

function resolveAndValidateImage(
  item: FlattenArray<OpenGraph['images'] | Twitter['images']>,
  metadataBase: MetadataBaseURL,
  isStaticMetadataRouteFile: boolean | undefined
) {
  if (!item) return undefined
  const isItemUrl = isStringOrURL(item)
  const inputUrl = isItemUrl ? item : item.url
  if (!inputUrl) return undefined

  // process.env.VERCEL is set to "1" when System Environment Variables are
  // exposed. When exposed, validation is not necessary since we are falling back to
  // process.env.VERCEL_PROJECT_PRODUCTION_URL, process.env.VERCEL_BRANCH_URL, or
  // process.env.VERCEL_URL for the `metadataBase`. process.env.VERCEL is undefined
  // when System Environment Variables are not exposed. When not exposed, we cannot
  // detect in the build environment if the deployment is a Vercel deployment or not.
  //
  // x-ref: https://vercel.com/docs/projects/environment-variables/system-environment-variables#system-environment-variables
  const isUsingVercelSystemEnvironmentVariables = Boolean(process.env.VERCEL)

  const isRelativeUrl =
    typeof inputUrl === 'string' && !isFullStringUrl(inputUrl)

  // When no explicit metadataBase is specified by the user, we'll override it with the fallback metadata
  // under the following conditions:
  // - The provided URL is relative (ie ./og-image).
  // - The image is statically generated by Next.js (such as the special `opengraph-image` route)
  // In both cases, we want to ensure that across all environments, the ogImage is a fully qualified URL.
  // In the `opengraph-image` case, since the user isn't explicitly passing a relative path, this ensures
  // the ogImage will be properly discovered across different environments without the user needing to
  // have a bunch of `process.env` checks when defining their `metadataBase`.
  if (isRelativeUrl && (!metadataBase || isStaticMetadataRouteFile)) {
    const fallbackMetadataBase =
      getSocialImageMetadataBaseFallback(metadataBase)

    // When not using Vercel environment variables for URL injection, we aren't able to determine
    // a fallback value for `metadataBase`. For self-hosted setups, we want to warn
    // about this since the only fallback we'll be able to generate is `localhost`.
    // In development, we'll only warn for relative metadata that isn't part of the static
    // metadata conventions (eg `opengraph-image`), as otherwise it's currently very noisy
    // for common cases. Eventually we should remove this warning all together in favor of
    // devtools.
    const shouldWarn =
      !isUsingVercelSystemEnvironmentVariables &&
      !metadataBase &&
      (process.env.NODE_ENV === 'production' || !isStaticMetadataRouteFile)

    if (shouldWarn) {
      warnOnce(
        `metadataBase property in metadata export is not set for resolving social open graph or twitter images, using "${fallbackMetadataBase.origin}". See https://nextjs.org/docs/app/api-reference/functions/generate-metadata#metadatabase`
      )
    }

    metadataBase = fallbackMetadataBase
  }

  return isItemUrl
    ? {
        url: resolveUrl(inputUrl, metadataBase),
      }
    : {
        ...item,
        // Update image descriptor url
        url: resolveUrl(inputUrl, metadataBase),
      }
}

export function resolveImages(
  images: Twitter['images'],
  metadataBase: MetadataBaseURL,
  isStaticMetadataRouteFile: boolean
): NonNullable<ResolvedMetadataWithURLs['twitter']>['images']
export function resolveImages(
  images: OpenGraph['images'],
  metadataBase: MetadataBaseURL,
  isStaticMetadataRouteFile: boolean
): NonNullable<ResolvedMetadataWithURLs['openGraph']>['images']
export function resolveImages(
  images: OpenGraph['images'] | Twitter['images'],
  metadataBase: MetadataBaseURL,
  isStaticMetadataRouteFile: boolean
):
  | NonNullable<ResolvedMetadataWithURLs['twitter']>['images']
  | NonNullable<ResolvedMetadataWithURLs['openGraph']>['images'] {
  const resolvedImages = resolveAsArrayOrUndefined(images)
  if (!resolvedImages) return resolvedImages

  const nonNullableImages = []
  for (const item of resolvedImages) {
    const resolvedItem = resolveAndValidateImage(
      item,
      metadataBase,
      isStaticMetadataRouteFile
    )
    if (!resolvedItem) continue

    nonNullableImages.push(resolvedItem)
  }

  return nonNullableImages
}

const ogTypeToFields: Record<string, readonly string[]> = {
  article: OgTypeFields.article,
  book: OgTypeFields.article,
  'music.song': OgTypeFields.song,
  'music.album': OgTypeFields.song,
  'music.playlist': OgTypeFields.playlist,
  'music.radio_station': OgTypeFields.radio,
  'video.movie': OgTypeFields.video,
  'video.episode': OgTypeFields.video,
}

function getFieldsByOgType(ogType: OpenGraphType | undefined) {
  if (!ogType || !(ogType in ogTypeToFields)) return OgTypeFields.basic
  return ogTypeToFields[ogType].concat(OgTypeFields.basic)
}

export const resolveOpenGraph: AsyncFieldResolverExtraArgs<
  'openGraph',
  [MetadataBaseURL, Promise<string>, MetadataContext, string | null]
> = async (
  openGraph,
  metadataBase,
  pathname,
  metadataContext,
  titleTemplate
) => {
  if (!openGraph) return null

  function resolveProps(target: ResolvedOpenGraph, og: OpenGraph) {
    const ogType = og && 'type' in og ? og.type : undefined
    const keys = getFieldsByOgType(ogType)
    for (const k of keys) {
      const key = k as keyof ResolvedOpenGraph
      if (key in og && key !== 'url') {
        const value = og[key]
        // TODO: improve typing inferring
        ;(target as any)[key] = value ? resolveArray(value) : null
      }
    }
    target.images = resolveImages(
      og.images,
      metadataBase,
      metadataContext.isStaticMetadataRouteFile
    )
  }

  const resolved = {
    ...openGraph,
    title: resolveTitle(openGraph.title, titleTemplate),
  } as ResolvedOpenGraph
  resolveProps(resolved, openGraph)

  resolved.url = openGraph.url
    ? resolveAbsoluteUrlWithPathname(
        openGraph.url,
        metadataBase,
        await pathname,
        metadataContext
      )
    : null

  return resolved
}

const TwitterBasicInfoKeys = [
  'site',
  'siteId',
  'creator',
  'creatorId',
  'description',
] as const

export const resolveTwitter: FieldResolverExtraArgs<
  'twitter',
  [MetadataBaseURL, MetadataContext, string | null]
> = (twitter, metadataBase, metadataContext, titleTemplate) => {
  if (!twitter) return null
  let card = 'card' in twitter ? twitter.card : undefined
  const resolved = {
    ...twitter,
    title: resolveTitle(twitter.title, titleTemplate),
  } as ResolvedTwitterMetadata
  for (const infoKey of TwitterBasicInfoKeys) {
    resolved[infoKey] = twitter[infoKey] || null
  }

  resolved.images = resolveImages(
    twitter.images,
    metadataBase,
    metadataContext.isStaticMetadataRouteFile
  )

  card = card || (resolved.images?.length ? 'summary_large_image' : 'summary')
  resolved.card = card

  if ('card' in resolved) {
    switch (resolved.card) {
      case 'player': {
        resolved.players = resolveAsArrayOrUndefined(resolved.players) || []
        break
      }
      case 'app': {
        resolved.app = resolved.app || {}
        break
      }
      case 'summary':
      case 'summary_large_image':
        break
      default:
        resolved satisfies never
    }
  }

  return resolved
}
Quest for Codev2.0.0
/
SIGN IN