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:
parent
b1d2200770
commit
9150620993
16 changed files with 350 additions and 56 deletions
|
@ -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",
|
||||
|
|
|
@ -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') {
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
)
|
||||
|
||||
|
|
|
@ -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') &&
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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:
|
||||
|
|
24
test/e2e/app-dir/metadata-dynamic-routes/app/apple-icon.tsx
Normal file
24
test/e2e/app-dir/metadata-dynamic-routes/app/apple-icon.tsx
Normal 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>
|
||||
)
|
||||
)
|
||||
}
|
|
@ -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,
|
||||
}
|
||||
)
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
export default function page() {
|
||||
return <>dynamic</>
|
||||
}
|
25
test/e2e/app-dir/metadata-dynamic-routes/app/icon.tsx
Normal file
25
test/e2e/app-dir/metadata-dynamic-routes/app/icon.tsx
Normal 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>
|
||||
)
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
)
|
||||
}
|
|
@ -5,5 +5,6 @@ export default function Page() {
|
|||
}
|
||||
|
||||
export const metadata = {
|
||||
metadataBase: new URL('https://deploy-preview-abc.vercel.app'),
|
||||
title: 'index page',
|
||||
}
|
||||
|
|
|
@ -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
|
||||
)
|
||||
}
|
|
@ -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'
|
||||
)
|
||||
})
|
||||
}
|
||||
)
|
||||
|
|
Loading…
Reference in a new issue