Support dynamic routes for social images and icons (#47425)

Redo #47372 , basically revert #47416 and upgrade og (https://github.com/vercel/og/pull/60)

Closes NEXT-264
Closes NEXT-266
This commit is contained in:
Jiachi Liu 2023-03-23 01:12:22 +01:00 committed by GitHub
parent b1d2200770
commit 9150620993
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 350 additions and 56 deletions

View file

@ -95,7 +95,7 @@
"@typescript-eslint/eslint-plugin": "4.29.1",
"@typescript-eslint/parser": "4.29.1",
"@vercel/fetch": "6.1.1",
"@vercel/og": "0.0.20",
"@vercel/og": "0.4.1",
"@webassemblyjs/ast": "1.11.1",
"@webassemblyjs/floating-point-hex-parser": "1.11.1",
"@webassemblyjs/helper-api-error": "1.11.1",

View file

@ -80,11 +80,13 @@ export async function createStaticMetadataFromRoute(
resolvePath,
isRootLayer,
loaderContext,
pageExtensions,
}: {
route: string
resolvePath: (pathname: string) => Promise<string>
isRootLayer: boolean
loaderContext: webpack.LoaderContext<any>
pageExtensions: string[]
}
) {
let hasStaticMetadataFiles = false
@ -106,22 +108,46 @@ export async function createStaticMetadataFromRoute(
const resolvedMetadataFiles = await enumMetadataFiles(
resolvedDir,
STATIC_METADATA_IMAGES[type].filename,
STATIC_METADATA_IMAGES[type].extensions,
pageExtensions.concat(STATIC_METADATA_IMAGES[type].extensions),
opts
)
resolvedMetadataFiles
.sort((a, b) => a.localeCompare(b))
.forEach((filepath) => {
const imageModule = `() => import(/* webpackMode: "eager" */ ${JSON.stringify(
`next-metadata-image-loader?${stringify({
route,
numericSizes:
type === 'twitter' || type === 'opengraph' ? '1' : undefined,
type,
})}!` +
filepath +
METADATA_RESOURCE_QUERY
)})`
const [filename, ext] = path.basename(filepath).split('.')
const isDynamicResource = pageExtensions.includes(ext)
// imageModule type: () => Promise<ImageMetaInfo>
const imageModule = isDynamicResource
? `(async () => {
let { alt, size, contentType } = await import(/* webpackMode: "lazy" */ ${JSON.stringify(
filepath
)})
const props = {
alt,
type: contentType,
url: ${JSON.stringify(route + '/' + filename)},
}
if (size) {
${
type === 'twitter' || type === 'opengraph'
? 'props.width = size.width; props.height = size.height;'
: 'props.sizes = size.width + "x" + size.height;'
}
}
return props
})`
: `() => import(/* webpackMode: "eager" */ ${JSON.stringify(
`next-metadata-image-loader?${stringify({
route,
numericSizes:
type === 'twitter' || type === 'opengraph' ? '1' : undefined,
type,
})}!` +
filepath +
METADATA_RESOURCE_QUERY
)})`
hasStaticMetadataFiles = true
if (type === 'favicon') {

View file

@ -111,6 +111,7 @@ async function createTreeCodeFromPath(
resolvePath,
resolveParallelSegments,
loaderContext,
pageExtensions,
}: {
resolver: (
pathname: string,
@ -121,6 +122,7 @@ async function createTreeCodeFromPath(
pathname: string
) => [key: string, segment: string | string[]][]
loaderContext: webpack.LoaderContext<AppLoaderOptions>
pageExtensions: string[]
}
) {
const splittedPath = pagePath.split(/[\\/]/)
@ -161,6 +163,7 @@ async function createTreeCodeFromPath(
resolvePath,
isRootLayer,
loaderContext,
pageExtensions,
})
}
} catch (err: any) {
@ -380,6 +383,7 @@ const nextAppLoader: AppLoader = async function nextAppLoader() {
resolvePath: (pathname: string) => resolve(this.rootContext, pathname),
resolveParallelSegments,
loaderContext: this,
pageExtensions,
})
if (!rootLayout) {

View file

@ -42,7 +42,6 @@ const filePath = fileURLToPath(resourceUrl).replace(${JSON.stringify(
)}, '')
const buffer = fs.readFileSync(filePath)
export function GET() {
return new NextResponse(buffer, {
headers: {
@ -56,7 +55,7 @@ export const dynamic = 'force-static'
`
}
function getDynamicRouteCode(resourcePath: string) {
function getDynamicTextRouteCode(resourcePath: string) {
return `\
import { NextResponse } from 'next/server'
import handler from ${JSON.stringify(resourcePath)}
@ -79,6 +78,19 @@ export async function GET() {
`
}
function getDynamicImageRouteCode(resourcePath: string) {
return `\
import { NextResponse } from 'next/server'
import handler from ${JSON.stringify(resourcePath)}
export async function GET(req, ctx) {
const res = await handler({ params: ctx.params })
res.headers.set('Cache-Control', 'public, max-age=0, must-revalidate')
return res
}
`
}
// `import.meta.url` is the resource name of the current module.
// When it's static route, it could be favicon.ico, sitemap.xml, robots.txt etc.
// TODO-METADATA: improve the cache control strategy
@ -87,12 +99,23 @@ const nextMetadataRouterLoader: webpack.LoaderDefinitionFunction<MetadataRouteLo
const { resourcePath } = this
const { pageExtensions } = this.getOptions()
const ext = path.extname(resourcePath).slice(1)
const isStatic = !pageExtensions.includes(ext)
const { name: fileBaseName, ext } = getFilenameAndExtension(resourcePath)
const isDynamic = pageExtensions.includes(ext)
const code = isStatic
? getStaticRouteCode(resourcePath)
: getDynamicRouteCode(resourcePath)
let code = ''
if (isDynamic) {
if (
fileBaseName === 'sitemap' ||
fileBaseName === 'robots' ||
fileBaseName === 'manifest'
) {
code = getDynamicTextRouteCode(resourcePath)
} else {
code = getDynamicImageRouteCode(resourcePath)
}
} else {
code = getStaticRouteCode(resourcePath)
}
return code
}

View file

@ -668,7 +668,7 @@ function getExtractMetadata(params: {
const resource = module.resource
const hasOGImageGeneration =
resource &&
/[\\/]node_modules[\\/]@vercel[\\/]og[\\/]dist[\\/]index.js$/.test(
/[\\/]node_modules[\\/]@vercel[\\/]og[\\/]dist[\\/]index\.(edge|node)\.js$/.test(
resource
)

View file

@ -48,7 +48,7 @@ export function isMetadataRouteFile(
`[\\\\/]${STATIC_METADATA_IMAGES.icon.filename}${
withExtension
? `\\.${getExtensionRegexString(
STATIC_METADATA_IMAGES.icon.extensions
pageExtensions.concat(STATIC_METADATA_IMAGES.icon.extensions)
)}`
: ''
}`
@ -57,7 +57,7 @@ export function isMetadataRouteFile(
`[\\\\/]${STATIC_METADATA_IMAGES.apple.filename}${
withExtension
? `\\.${getExtensionRegexString(
STATIC_METADATA_IMAGES.apple.extensions
pageExtensions.concat(STATIC_METADATA_IMAGES.apple.extensions)
)}`
: ''
}`
@ -66,7 +66,7 @@ export function isMetadataRouteFile(
`[\\\\/]${STATIC_METADATA_IMAGES.opengraph.filename}${
withExtension
? `\\.${getExtensionRegexString(
STATIC_METADATA_IMAGES.opengraph.extensions
pageExtensions.concat(STATIC_METADATA_IMAGES.opengraph.extensions)
)}`
: ''
}`
@ -75,7 +75,7 @@ export function isMetadataRouteFile(
`[\\\\/]${STATIC_METADATA_IMAGES.twitter.filename}${
withExtension
? `\\.${getExtensionRegexString(
STATIC_METADATA_IMAGES.twitter.extensions
pageExtensions.concat(STATIC_METADATA_IMAGES.twitter.extensions)
)}`
: ''
}`
@ -85,9 +85,16 @@ export function isMetadataRouteFile(
return metadataRouteFilesRegex.some((r) => r.test(appDirRelativePath))
}
/*
* Remove the 'app' prefix or '/route' suffix, only check the route name since they're only allowed in root app directory
* e.g.
* /app/robots -> /robots
* app/robots -> /robots
* /robots -> /robots
*/
export function isMetadataRoute(route: string): boolean {
// Remove the 'app' prefix or '/route' suffix, only check the route name since they're only allowed in root app directory
const page = route.replace(/^\/?app/, '').replace(/\/route$/, '')
let page = route.replace(/^\/?app\//, '').replace(/\/route$/, '')
if (page[0] !== '/') page = '/' + page
return (
!page.endsWith('/page') &&

View file

@ -45,9 +45,13 @@ function mergeStaticMetadata(
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 (!metadata.icons)
metadata.icons = {
icon: icon || [],
apple: apple || [],
}
// if (icon) metadata.icons.icon.push(...icon)
// if (apple) metadata.icons.apple.push(...apple)
}
if (twitter) {
const resolvedTwitter = resolveTwitter(
@ -215,9 +219,8 @@ async function collectStaticImagesFiles(
if (!metadata?.[type]) return undefined
const iconPromises = metadata[type as 'icon' | 'apple'].map(
// TODO-APP: share the typing between next-metadata-image-loader and here
async (iconResolver: any) =>
interopDefault(await iconResolver()) as MetadataImageModule
async (iconResolver: () => Promise<MetadataImageModule>) =>
interopDefault(await iconResolver())
)
return iconPromises?.length > 0 ? await Promise.all(iconPromises) : undefined
}

View file

@ -55,7 +55,7 @@ importers:
'@typescript-eslint/eslint-plugin': 4.29.1
'@typescript-eslint/parser': 4.29.1
'@vercel/fetch': 6.1.1
'@vercel/og': 0.0.20
'@vercel/og': 0.4.1
'@webassemblyjs/ast': 1.11.1
'@webassemblyjs/floating-point-hex-parser': 1.11.1
'@webassemblyjs/helper-api-error': 1.11.1
@ -233,7 +233,7 @@ importers:
'@typescript-eslint/eslint-plugin': 4.29.1_qxyn66xcaddhgaahwkbomftvi4
'@typescript-eslint/parser': 4.29.1_6x3mpmmsttbpxxsctsorxedanu
'@vercel/fetch': 6.1.1_fii5qhbaymjqmfm7e2spxc5z4m
'@vercel/og': 0.0.20
'@vercel/og': 0.4.1
'@webassemblyjs/ast': 1.11.1
'@webassemblyjs/floating-point-hex-parser': 1.11.1
'@webassemblyjs/helper-api-error': 1.11.1
@ -6310,8 +6310,8 @@ packages:
resolution: {integrity: sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==}
dev: true
/@resvg/resvg-wasm/2.0.0-alpha.4:
resolution: {integrity: sha512-pWIG9a/x1ky8gXKRhPH1OPKpHFoMN1ISLbJ+O+gPXQHIAKhNd5I28RlWf7q576hAOQA9JZTlo3p/M2uyLzJmmw==}
/@resvg/resvg-wasm/2.4.1:
resolution: {integrity: sha512-yi6R0HyHtsoWTRA06Col4WoDs7SvlXU3DLMNP2bdAgs7HK18dTEVl1weXgxRzi8gwLteGUbIg29zulxIB3GSdg==}
engines: {node: '>= 10'}
dev: true
@ -7496,10 +7496,6 @@ packages:
'@types/yargs-parser': 13.1.0
dev: true
/@types/yoga-layout/1.9.2:
resolution: {integrity: sha512-S9q47ByT2pPvD65IvrWp7qppVMpk9WGMbVq9wbWZOHg6tnXSD4vyhao6nOSBwwfDdV2p3Kx9evA9vI+XWTfDvw==}
dev: true
/@typescript-eslint/eslint-plugin/4.29.1_qxyn66xcaddhgaahwkbomftvi4:
resolution: {integrity: sha512-AHqIU+SqZZgBEiWOrtN94ldR3ZUABV5dUG94j8Nms9rQnHFc8fvDOue/58K4CFz6r8OtDDc35Pw9NQPWo0Ayrw==}
engines: {node: ^10.12.0 || >=12.0.0}
@ -7737,13 +7733,13 @@ packages:
- supports-color
dev: true
/@vercel/og/0.0.20:
resolution: {integrity: sha512-089P+TfqWz0xBxjOvOhkZIDDtfrLcye94H4IZ+SqxoGPWpNGXaBvRJER/z5SoJxJRcCAL8tPiK5zdjRskM6tLw==}
/@vercel/og/0.4.1:
resolution: {integrity: sha512-N8SXkbmQQRK0iHpqwUe50Fe8JO3sx2ByV6hgPTi16ONiMdqOM9uzE4QMWoY1Oa0uYZuUVA30sEB84mVMn+gT3w==}
engines: {node: '>=16'}
dependencies:
'@resvg/resvg-wasm': 2.0.0-alpha.4
satori: 0.0.43
yoga-wasm-web: 0.1.2
'@resvg/resvg-wasm': 2.4.1
satori: 0.4.1
yoga-wasm-web: 0.3.0
dev: true
/@webassemblyjs/ast/1.11.1:
@ -8836,6 +8832,11 @@ packages:
mixin-deep: 1.3.2
pascalcase: 0.1.1
/base64-js/0.0.8:
resolution: {integrity: sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==}
engines: {node: '>= 0.4'}
dev: true
/base64-js/1.5.1:
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
dev: true
@ -16410,6 +16411,13 @@ packages:
resolution: {integrity: sha512-FWWMIEOxz3GwUI4Ts/IvgVy6LPvoMPgjMdQ185nN6psJyBJ4yOpzqm695/h5umdLJg2vW3GR5iG11MAkR2AzJA==}
dev: true
/linebreak/1.1.0:
resolution: {integrity: sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==}
dependencies:
base64-js: 0.0.8
unicode-trie: 2.0.0
dev: true
/lines-and-columns/1.1.6:
resolution: {integrity: sha512-8ZmlJFVK9iCmtLz19HpSsR8HaAMWBT284VMNednLwlIMDP2hJDCIhUp0IZ2xUcZ+Ob6BM0VvCSJwzASDM45NLQ==}
@ -22278,8 +22286,8 @@ packages:
immutable: 4.1.0
source-map-js: 1.0.2
/satori/0.0.43:
resolution: {integrity: sha512-SzYwr+LsELWRJU9KMviEOE9TdShry+R5AdS54YQvgAVKFDN4yniAIzwQk1/z2TtIx0ceUT9zTeosWAoWvJBEtQ==}
/satori/0.4.1:
resolution: {integrity: sha512-uQm4+vQj57E9PO/ASAlxsRb+78fHkRUmkNLBBmfuEeGNLGqJ9ufYU7TzYP4XH60PLHYt9Wz1jSsgcAe2cz7+ng==}
engines: {node: '>=16'}
dependencies:
'@shuding/opentype.js': 1.4.0-beta.0
@ -22287,8 +22295,9 @@ packages:
css-box-shadow: 1.0.0-3
css-to-react-native: 3.0.0
emoji-regex: 10.2.1
linebreak: 1.1.0
postcss-value-parser: 4.2.0
yoga-layout-prebuilt: 1.10.0
yoga-wasm-web: 0.3.3
dev: true
/sax/1.2.4:
@ -25401,15 +25410,12 @@ packages:
engines: {node: '>=12.20'}
dev: true
/yoga-layout-prebuilt/1.10.0:
resolution: {integrity: sha512-YnOmtSbv4MTf7RGJMK0FvZ+KD8OEe/J5BNnR0GHhD8J/XcG/Qvxgszm0Un6FTHWW4uHlTgP0IztiXQnGyIR45g==}
engines: {node: '>=8'}
dependencies:
'@types/yoga-layout': 1.9.2
/yoga-wasm-web/0.3.0:
resolution: {integrity: sha512-rD3L4jyMlO1m+RWU60lNwZQK5zmzglCV5fI1gTRikmpv3YzmNIZQbjyfE6cMNb9Xaly/C1SwemYGbsiOekMvnQ==}
dev: true
/yoga-wasm-web/0.1.2:
resolution: {integrity: sha512-8SkgawHcA0RUbMrnhxbaQkZDBi8rMed8pQHixkFF9w32zGhAwZ9/cOHWlpYfr6RCx42Yp3siV45/jPEkJxsk6w==}
/yoga-wasm-web/0.3.3:
resolution: {integrity: sha512-N+d4UJSJbt/R3wqY7Coqs5pcV0aUj2j9IaQ3rNj9bVCLld8tTGKRa2USARjnvZJWVx1NDmQev8EknoczaOQDOA==}
dev: true
/zod/3.21.4:

View file

@ -0,0 +1,24 @@
import { ImageResponse } from '@vercel/og'
export const contentType = 'image/png'
export default function appleIcon() {
return new ImageResponse(
(
<div
style={{
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 88,
background: '#fff',
color: '#000',
}}
>
Apple Icon
</div>
)
)
}

View file

@ -0,0 +1,34 @@
import { ImageResponse } from '@vercel/og'
export const alt = 'Open Graph'
export default function og({ params }) {
const big = params.size === 'big'
const background = big ? 'orange' : '#000'
return new ImageResponse(
(
<div
style={{
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 128,
}}
>
<div
style={{
width: 200,
height: 200,
background,
}}
/>
</div>
),
{
width: big === true ? 1200 : 600,
height: big === true ? 630 : 315,
}
)
}

View file

@ -0,0 +1,3 @@
export default function page() {
return <>dynamic</>
}

View file

@ -0,0 +1,25 @@
import { ImageResponse } from '@vercel/og'
export const contentType = 'image/png'
export const size = { width: 512, height: 512 }
export default function icon() {
return new ImageResponse(
(
<div
style={{
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 88,
background: '#fff',
color: '#000',
}}
>
Icon
</div>
)
)
}

View file

@ -0,0 +1,23 @@
import { ImageResponse } from '@vercel/og'
export const alt = 'Open Graph'
export default function og() {
return new ImageResponse(
(
<div
style={{
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 128,
background: 'lavender',
}}
>
Open Graph
</div>
)
)
}

View file

@ -5,5 +5,6 @@ export default function Page() {
}
export const metadata = {
metadataBase: new URL('https://deploy-preview-abc.vercel.app'),
title: 'index page',
}

View file

@ -0,0 +1,25 @@
import { ImageResponse } from '@vercel/og'
export const alt = 'Twitter'
export const size = { width: 1600, height: 900 }
export default function twitter() {
return new ImageResponse(
(
<div
style={{
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 128,
background: 'lavender',
}}
>
Twitter Image
</div>
),
size
)
}

View file

@ -1,13 +1,17 @@
import { createNextDescribe } from 'e2e-utils'
import imageSize from 'image-size'
createNextDescribe(
'app dir - metadata dynamic routes',
{
files: __dirname,
skipDeployment: true,
dependencies: {
'@vercel/og': '0.4.1',
},
},
({ next }) => {
describe('dynamic routes', () => {
describe('text routes', () => {
it('should handle robots.[ext] dynamic routes', async () => {
const res = await next.fetch('/robots.txt')
const text = await res.text()
@ -56,7 +60,9 @@ createNextDescribe(
"
`)
})
})
describe('social image routes', () => {
it('should handle manifest.[ext] dynamic routes', async () => {
const res = await next.fetch('/manifest.webmanifest')
const json = await res.json()
@ -85,6 +91,90 @@ createNextDescribe(
],
})
})
it('should render og image with opengraph-image dynamic routes', async () => {
const res = await next.fetch('/opengraph-image')
expect(res.headers.get('content-type')).toBe('image/png')
expect(res.headers.get('cache-control')).toBe(
'public, max-age=0, must-revalidate'
)
})
it('should render og image with twitter-image dynamic routes', async () => {
const res = await next.fetch('/twitter-image')
expect(res.headers.get('content-type')).toBe('image/png')
expect(res.headers.get('cache-control')).toBe(
'public, max-age=0, must-revalidate'
)
})
it('should support params as argument in dynamic routes', async () => {
const bufferBig = await (
await next.fetch('/dynamic/big/opengraph-image')
).buffer()
const bufferSmall = await (
await next.fetch('/dynamic/small/opengraph-image')
).buffer()
const sizeBig = imageSize(bufferBig)
const sizeSmall = imageSize(bufferSmall)
expect([sizeBig.width, sizeBig.height]).toEqual([1200, 630])
expect([sizeSmall.width, sizeSmall.height]).toEqual([600, 315])
})
})
describe('icon image routes', () => {
it('should render icon with dynamic routes', async () => {
const res = await next.fetch('/icon')
expect(res.headers.get('content-type')).toBe('image/png')
expect(res.headers.get('cache-control')).toBe(
'public, max-age=0, must-revalidate'
)
})
it('should render apple icon with dynamic routes', async () => {
const res = await next.fetch('/apple-icon')
expect(res.headers.get('content-type')).toBe('image/png')
expect(res.headers.get('cache-control')).toBe(
'public, max-age=0, must-revalidate'
)
})
})
it('should inject dynamic metadata properly to head', async () => {
const $ = await next.render$('/')
const $icon = $('link[rel="icon"]')
const $appleIcon = $('link[rel="apple-touch-icon"]')
const ogImageUrl = $('meta[property="og:image"]').attr('content')
const twitterImageUrl = $('meta[name="twitter:image"]').attr('content')
// non absolute urls
expect($icon.attr('href')).toBe('/icon')
expect($icon.attr('sizes')).toBe('512x512')
expect($icon.attr('type')).toBe('image/png')
expect($appleIcon.attr('href')).toBe('/apple-icon')
expect($appleIcon.attr('sizes')).toBe(undefined)
expect($appleIcon.attr('type')).toBe('image/png')
// absolute urls
expect(ogImageUrl).toBe(
'https://deploy-preview-abc.vercel.app/opengraph-image'
)
expect(twitterImageUrl).toBe(
'https://deploy-preview-abc.vercel.app/twitter-image'
)
// alt text
expect($('meta[property="og:image:alt"]').attr('content')).toBe(
'Open Graph'
)
expect($('meta[name="twitter:image:alt"]').attr('content')).toBe(
'Twitter'
)
})
}
)