Static og and twitter image files as metadata (#45797)

## Feature

Closes NEXT-265
Fixes NEXT-516


- [x] Implements an existing feature request or RFC. Make sure the feature request has been accepted for implementation before opening a PR.
- [x] Related issues linked using `fixes #number`
- [x] [e2e](https://github.com/vercel/next.js/blob/canary/contributing/core/testing.md#writing-tests-for-nextjs) tests added
- [ ] Documentation added
- [ ] Telemetry added. In case of a feature if it's used or not.
- [ ] Errors have a helpful link attached, see [`contributing.md`](https://github.com/vercel/next.js/blob/canary/contributing.md)
This commit is contained in:
Jiachi Liu 2023-02-13 14:57:55 +01:00 committed by GitHub
parent 8fb2f59169
commit a5fe64cd06
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 213 additions and 101 deletions

View file

@ -61,7 +61,7 @@ import { AppBuildManifestPlugin } from './webpack/plugins/app-build-manifest-plu
import { SubresourceIntegrityPlugin } from './webpack/plugins/subresource-integrity-plugin'
import { FontLoaderManifestPlugin } from './webpack/plugins/font-loader-manifest-plugin'
import { getSupportedBrowsers } from './utils'
import { METADATA_IMAGE_RESOURCE_QUERY } from './webpack/loaders/app-dir/metadata'
import { METADATA_IMAGE_RESOURCE_QUERY } from './webpack/loaders/metadata/discover'
const EXTERNAL_PACKAGES = require('../lib/server-external-packages.json')

View file

@ -1,11 +0,0 @@
// TODO-APP: check if this can be narrowed.
export type ComponentModule = () => any
export type ModuleReference = [
componentModule: ComponentModule,
filePath: string
]
export type CollectedMetadata = {
icon: ComponentModule[]
apple: ComponentModule[]
}

View file

@ -1,8 +1,16 @@
import type webpack from 'webpack'
import type { AppLoaderOptions } from '../next-app-loader'
import type { CollectingMetadata } from './types'
import path from 'path'
import { stringify } from 'querystring'
type PossibleImageFileNameConvention =
| 'icon'
| 'apple'
| 'favicon'
| 'twitter'
| 'opengraph'
const METADATA_TYPE = 'metadata'
export const METADATA_IMAGE_RESOURCE_QUERY = '?__next_metadata'
@ -14,12 +22,20 @@ const staticAssetIconsImage = {
},
apple: {
filename: 'apple-icon',
extensions: ['jpg', 'jpeg', 'png', 'svg'],
extensions: ['jpg', 'jpeg', 'png'],
},
favicon: {
filename: 'favicon',
extensions: ['ico'],
},
opengraph: {
filename: 'opengraph-image',
extensions: ['jpg', 'jpeg', 'png', 'gif'],
},
twitter: {
filename: 'twitter-image',
extensions: ['jpg', 'jpeg', 'png', 'gif'],
},
}
// Produce all compositions with filename (icon, apple-icon, etc.) with extensions (png, jpg, etc.)
@ -77,12 +93,11 @@ export async function discoverStaticMetadataFiles(
}
) {
let hasStaticMetadataFiles = false
const iconsMetadata: {
icon: string[]
apple: string[]
} = {
const staticImagesMetadata: CollectingMetadata = {
icon: [],
apple: [],
twitter: [],
opengraph: [],
}
const opts = {
@ -95,7 +110,9 @@ export async function discoverStaticMetadataFiles(
assetPrefix: loaderOptions.assetPrefix,
}
async function collectIconModuleIfExists(type: 'icon' | 'apple' | 'favicon') {
async function collectIconModuleIfExists(
type: PossibleImageFileNameConvention
) {
const resolvedMetadataFiles = await enumMetadataFiles(
resolvedDir,
staticAssetIconsImage[type].filename,
@ -105,19 +122,21 @@ export async function discoverStaticMetadataFiles(
resolvedMetadataFiles
.sort((a, b) => a.localeCompare(b))
.forEach((filepath) => {
const iconModule = `() => import(/* webpackMode: "eager" */ ${JSON.stringify(
`next-metadata-image-loader?${stringify(
metadataImageLoaderOptions
)}!` +
const imageModule = `() => import(/* webpackMode: "eager" */ ${JSON.stringify(
`next-metadata-image-loader?${stringify({
...metadataImageLoaderOptions,
numericSizes:
type === 'twitter' || type === 'opengraph' ? '1' : undefined,
})}!` +
filepath +
METADATA_IMAGE_RESOURCE_QUERY
)})`
hasStaticMetadataFiles = true
if (type === 'favicon') {
iconsMetadata.icon.unshift(iconModule)
staticImagesMetadata.icon.unshift(imageModule)
} else {
iconsMetadata[type].push(iconModule)
staticImagesMetadata[type].push(imageModule)
}
})
}
@ -125,10 +144,12 @@ export async function discoverStaticMetadataFiles(
await Promise.all([
collectIconModuleIfExists('icon'),
collectIconModuleIfExists('apple'),
collectIconModuleIfExists('opengraph'),
collectIconModuleIfExists('twitter'),
isRootLayer && collectIconModuleIfExists('favicon'),
])
return hasStaticMetadataFiles ? iconsMetadata : null
return hasStaticMetadataFiles ? staticImagesMetadata : null
}
export function buildMetadata(
@ -137,7 +158,9 @@ export function buildMetadata(
return metadata
? `${METADATA_TYPE}: {
icon: [${metadata.icon.join(',')}],
apple: [${metadata.apple.join(',')}]
apple: [${metadata.apple.join(',')}],
opengraph: [${metadata.opengraph.join(',')}],
twitter: [${metadata.twitter.join(',')}],
}`
: ''
}

View file

@ -0,0 +1,33 @@
// TODO-APP: check if this can be narrowed.
export type ComponentModule = () => any
export type ModuleReference = [
componentModule: ComponentModule,
filePath: string
]
// Contain the collecting image module paths
export type CollectingMetadata = {
icon: string[]
apple: string[]
twitter: string[]
opengraph: string[]
}
// Contain the collecting evaluated image module
export type CollectedMetadata = {
icon: ComponentModule[]
apple: ComponentModule[]
twitter: ComponentModule[] | null
opengraph: ComponentModule[] | null
}
export type MetadataImageModule = {
url: string
type?: string
} & (
| { sizes?: string }
| {
width?: number
height?: number
}
)

View file

@ -1,6 +1,6 @@
import type webpack from 'webpack'
import type { ValueOf } from '../../../shared/lib/constants'
import type { ModuleReference, CollectedMetadata } from './app-dir/types'
import type { ModuleReference, CollectedMetadata } from './metadata/types'
import path from 'path'
import chalk from 'next/dist/compiled/chalk'
@ -9,7 +9,7 @@ import { getModuleBuildInfo } from './get-module-build-info'
import { verifyRootLayout } from '../../../lib/verifyRootLayout'
import * as Log from '../../../build/output/log'
import { APP_DIR_ALIAS } from '../../../lib/constants'
import { buildMetadata, discoverStaticMetadataFiles } from './app-dir/metadata'
import { buildMetadata, discoverStaticMetadataFiles } from './metadata/discover'
const isNotResolvedError = (err: any) => err.message.includes("Can't resolve")

View file

@ -1,10 +1,11 @@
import type { MetadataImageModule } from './metadata/types'
import loaderUtils from 'next/dist/compiled/loader-utils3'
import { getImageSize } from '../../../server/image-optimizer'
interface Options {
isServer: boolean
isDev: boolean
assetPrefix: string
numericSizes: boolean
}
const mimeTypeMap = {
@ -12,11 +13,13 @@ const mimeTypeMap = {
png: 'image/png',
ico: 'image/x-icon',
svg: 'image/svg+xml',
} as const
avif: 'image/avif',
webp: 'image/webp',
}
async function nextMetadataImageLoader(this: any, content: Buffer) {
const options: Options = this.getOptions()
const { assetPrefix, isDev } = options
const { assetPrefix, isDev, numericSizes } = options
const context = this.rootContext
const opts = { context, content }
@ -41,14 +44,22 @@ async function nextMetadataImageLoader(this: any, content: Buffer) {
throw err
}
const stringifiedData = JSON.stringify({
const imageData: MetadataImageModule = {
url: outputPath,
...(extension in mimeTypeMap && {
type: mimeTypeMap[extension as keyof typeof mimeTypeMap],
}),
sizes:
extension === 'ico' ? 'any' : `${imageSize.width}x${imageSize.height}`,
})
...(numericSizes
? { width: imageSize.width as number, height: imageSize.height as number }
: {
sizes:
extension === 'ico'
? 'any'
: `${imageSize.width}x${imageSize.height}`,
}),
}
const stringifiedData = JSON.stringify(imageData)
this.emitFile(`../${isDev ? '' : '../'}${interpolatedName}`, content, null)

View file

@ -1,13 +0,0 @@
import React from 'react'
const MetadataContext = (React as any).createServerContext(null)
if (process.env.NODE_ENV !== 'production') {
MetadataContext.displayName = 'MetadataContext'
}
export function useMetadataBase() {
return React.useContext(MetadataContext)
}
export default MetadataContext

View file

@ -4,6 +4,7 @@ import type {
ResolvingMetadata,
} from './types/metadata-interface'
import type { AbsoluteTemplateString } from './types/metadata-types'
import type { MetadataImageModule } from '../../build/webpack/loaders/metadata/types'
import { createDefaultMetadata } from './default-metadata'
import { resolveOpenGraph, resolveTwitter } from './resolvers/resolve-opengraph'
import { mergeTitle } from './resolvers/resolve-title'
@ -25,17 +26,63 @@ import {
} from './resolvers/resolve-basics'
import { resolveIcons } from './resolvers/resolve-icons'
type StaticMetadata = Awaited<ReturnType<typeof resolveStaticMetadata>>
type MetadataResolver = (
_parent: ResolvingMetadata
) => Metadata | Promise<Metadata>
export type MetadataItems = [
Metadata | MetadataResolver | null,
StaticMetadata
][]
function mergeStaticMetadata(
metadata: ResolvedMetadata,
staticFilesMetadata: StaticMetadata
) {
if (!staticFilesMetadata) return
const { icon, apple, opengraph, twitter } = staticFilesMetadata
if (icon || apple) {
if (!metadata.icons) metadata.icons = { icon: [], apple: [] }
if (icon) metadata.icons.icon.push(...icon)
if (apple) metadata.icons.apple.push(...apple)
}
if (twitter) {
const resolvedTwitter = resolveTwitter(
{
card: 'summary_large_image',
images: twitter,
},
null
)
metadata.twitter = { ...metadata.twitter, ...resolvedTwitter! }
}
if (opengraph) {
const resolvedOg = resolveOpenGraph(
{
images: opengraph,
},
null
)
metadata.openGraph = { ...metadata.openGraph, ...resolvedOg! }
}
return metadata
}
// Merge the source metadata into the resolved target metadata.
function merge(
target: ResolvedMetadata,
source: Metadata,
source: Metadata | null,
staticFilesMetadata: StaticMetadata,
templateStrings: {
title: string | null
openGraph: string | null
twitter: string | null
}
) {
const metadataBase = source.metadataBase || null
const metadataBase = source?.metadataBase || null
for (const key_ in source) {
const key = key_ as keyof Metadata
@ -122,16 +169,9 @@ function merge(
break
}
}
mergeStaticMetadata(target, staticFilesMetadata)
}
type MetadataResolver = (
_parent: ResolvingMetadata
) => Metadata | Promise<Metadata>
export type MetadataItems = [
Metadata | MetadataResolver | null,
Metadata | null
][]
async function getDefinedMetadata(
mod: any,
props: any
@ -156,35 +196,39 @@ async function getDefinedMetadata(
)
}
async function collectStaticFsBasedIcons(
async function collectStaticImagesFiles(
metadata: ComponentsType['metadata'],
type: 'icon' | 'apple'
type: keyof NonNullable<ComponentsType['metadata']>
) {
if (!metadata?.[type]) return undefined
const iconPromises = metadata[type].map(
const iconPromises = metadata[type as 'icon' | 'apple'].map(
// TODO-APP: share the typing between next-metadata-image-loader and here
async (iconResolver) =>
interopDefault(await iconResolver()) as { url: string; sizes: string }
async (iconResolver: any) =>
interopDefault(await iconResolver()) as MetadataImageModule
)
return iconPromises?.length > 0 ? await Promise.all(iconPromises) : undefined
}
async function resolveStaticMetadata(
components: ComponentsType
): Promise<Metadata | null> {
async function resolveStaticMetadata(components: ComponentsType) {
const { metadata } = components
if (!metadata) return null
const [icon, apple] = await Promise.all([
collectStaticFsBasedIcons(metadata, 'icon'),
collectStaticFsBasedIcons(metadata, 'apple'),
const [icon, apple, opengraph, twitter] = await Promise.all([
collectStaticImagesFiles(metadata, 'icon'),
collectStaticImagesFiles(metadata, 'apple'),
collectStaticImagesFiles(metadata, 'opengraph'),
collectStaticImagesFiles(metadata, 'twitter'),
])
const icons: Metadata['icons'] = {}
if (icon) icons.icon = icon
if (apple) icons.apple = apple
const staticMetadata = {
icon,
apple,
opengraph,
twitter,
}
return { icons }
return staticMetadata
}
// [layout.metadata, static files metadata] -> ... -> [page.metadata, static files metadata]
@ -208,28 +252,22 @@ export async function accumulateMetadata(
for (const item of metadataItems) {
const [metadataExport, staticFilesMetadata] = item
const layerMetadataPromise = Promise.resolve(
const currentMetadata =
typeof metadataExport === 'function'
? metadataExport(parentPromise)
: metadataExport
)
const layerMetadataPromise =
currentMetadata instanceof Promise
? currentMetadata
: Promise.resolve(currentMetadata)
parentPromise = parentPromise.then((resolved) => {
return layerMetadataPromise.then((exportedMetadata) => {
const metadata = exportedMetadata || staticFilesMetadata
if (metadata) {
// Overriding the metadata if static files metadata is present
merge(
resolved,
{ ...metadata, ...staticFilesMetadata },
{
title: resolved.title?.template || null,
openGraph: resolved.openGraph?.title?.template || null,
twitter: resolved.twitter?.title?.template || null,
}
)
}
return layerMetadataPromise.then((metadata) => {
merge(resolved, metadata, staticFilesMetadata, {
title: resolved.title?.template || null,
openGraph: resolved.openGraph?.title?.template || null,
twitter: resolved.twitter?.title?.template || null,
})
return resolved
})

View file

@ -17,7 +17,10 @@ export const resolveIcons: FieldResolver<'icons'> = (icons) => {
return null
}
const resolved: ResolvedMetadata['icons'] = {}
const resolved: ResolvedMetadata['icons'] = {
icon: [],
apple: [],
}
if (Array.isArray(icons)) {
resolved.icon = icons.map(resolveIcon).filter(Boolean)
} else if (isStringOrURL(icons)) {

View file

@ -100,11 +100,11 @@ export const resolveTwitter: FieldResolverWithMetadataBase<'twitter'> = (
resolved.images = resolveAsArrayOrUndefined(twitter.images)?.map((item) => {
if (isStringOrURL(item))
return {
url: resolveUrl(item, metadataBase),
url: metadataBase ? resolveUrl(item, metadataBase) : item,
}
else {
return {
url: resolveUrl(item.url, metadataBase),
url: metadataBase ? resolveUrl(item.url, metadataBase) : item.url,
alt: item.alt,
}
}

View file

@ -119,8 +119,8 @@ export type ResolvedVerification = {
}
export type ResolvedIcons = {
icon?: IconDescriptor[]
icon: IconDescriptor[]
apple: IconDescriptor[]
shortcut?: IconDescriptor[]
apple?: IconDescriptor[]
other?: IconDescriptor[]
}

View file

@ -34,7 +34,7 @@ type Locale = string
type OpenGraphMetadata = {
determiner?: 'a' | 'an' | 'the' | 'auto' | ''
title?: TemplateString
title?: string | TemplateString
description?: string
emails?: string | Array<string>
phoneNumbers?: string | Array<string>
@ -174,7 +174,7 @@ type ResolvedOpenGraphMetadata = {
images?: Array<OGImage>
audio?: Array<OGAudio>
videos?: Array<OGVideo>
url: null | URL
url: null | URL | string
countryName?: string
ttl?: number
}

View file

@ -64,7 +64,7 @@ type TwitterPlayerDescriptor = {
}
type ResolvedTwitterImage = {
url: null | URL
url: null | URL | string
alt?: string
}
type ResolvedTwitterSummary = {

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

View file

@ -0,0 +1,3 @@
export default function Page() {
return 'opengraph-static'
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

View file

@ -407,6 +407,31 @@ createNextDescribe(
'author3',
])
})
it('should pick up opengraph-image and twitter-image as static metadata files', async () => {
const $ = await next.render$('/opengraph/static')
expect($('[property="og:image:url"]').attr('content')).toMatch(
/_next\/static\/media\/metadata\/opengraph-image.\w+.png/
)
expect($('[property="og:image:type"]').attr('content')).toBe(
'image/png'
)
expect($('[property="og:image:width"]').attr('content')).toBe('114')
expect($('[property="og:image:height"]').attr('content')).toBe('114')
expect($('[name="twitter:image"]').attr('content')).toMatch(
/_next\/static\/media\/metadata\/twitter-image.\w+.png/
)
expect($('[name="twitter:card"]').attr('content')).toBe(
'summary_large_image'
)
// favicon shouldn't be overridden
const $icon = $('link[rel="icon"]')
expect($icon.attr('href')).toMatch(
/_next\/static\/media\/metadata\/favicon.\w+.ico/
)
})
})
describe('icons', () => {
@ -477,7 +502,7 @@ createNextDescribe(
it('should render icon and apple touch icon meta if their images are specified', async () => {
const $ = await next.render$('/icons/static/nested')
const $icon = $('head > link[rel="icon"]')
const $icon = $('head > link[rel="icon"][type!="image/x-icon"]')
const $appleIcon = $('head > link[rel="apple-touch-icon"]')
expect($icon.attr('href')).toMatch(
@ -495,7 +520,7 @@ createNextDescribe(
it('should not render if image file is not specified', async () => {
const $ = await next.render$('/icons/static')
const $icon = $('head > link[rel="icon"]')
const $icon = $('head > link[rel="icon"][type!="image/x-icon"]')
const $appleIcon = $('head > link[rel="apple-touch-icon"]')
expect($icon.attr('href')).toMatch(
@ -515,7 +540,7 @@ createNextDescribe(
await check(async () => {
const $ = await next.render$('/icons/static')
const $icon = $('head > link[rel="icon"]')
const $icon = $('head > link[rel="icon"][type!="image/x-icon"]')
return $icon.attr('href')
}, /\/_next\/static\/media\/metadata\/icon2\.\w+\.png/)