From 1f392b39a9d144ac6ca2f0c23815352473014eb3 Mon Sep 17 00:00:00 2001 From: Jiachi Liu Date: Tue, 24 Jan 2023 22:02:45 +0100 Subject: [PATCH 01/10] feat: apple related metadata and formatd etection --- .../next/src/lib/metadata/generate/basic.tsx | 69 ++++++++++++++++++ packages/next/src/lib/metadata/metadata.tsx | 10 ++- .../next/src/lib/metadata/resolve-metadata.ts | 40 ++++++++++- .../src/lib/metadata/types/extra-types.ts | 7 ++ .../lib/metadata/types/metadata-interface.ts | 10 +-- .../src/lib/metadata/types/metadata-types.ts | 8 +++ test/e2e/app-dir/metadata/app/apple/page.js | 21 ++++++ test/e2e/app-dir/metadata/app/basic/page.js | 5 ++ test/e2e/app-dir/metadata/metadata.test.ts | 72 ++++++++++++++++++- 9 files changed, 232 insertions(+), 10 deletions(-) create mode 100644 test/e2e/app-dir/metadata/app/apple/page.js diff --git a/packages/next/src/lib/metadata/generate/basic.tsx b/packages/next/src/lib/metadata/generate/basic.tsx index 6e838e32dff86..84f3687118a45 100644 --- a/packages/next/src/lib/metadata/generate/basic.tsx +++ b/packages/next/src/lib/metadata/generate/basic.tsx @@ -54,3 +54,72 @@ export function ResolvedBasicMetadata({ ) } + +export function ItunesMeta({ itunes }: { itunes: ResolvedMetadata['itunes'] }) { + if (!itunes) return null + const { appId, appArgument } = itunes + let content = `app-id=${appId}` + if (appArgument) { + content += ', ' + `app-argument=${appArgument}` + } + return +} + +const formatDetectionKeys = [ + 'telephone', + 'date', + 'address', + 'email', + 'url', +] as const +export function FormatDetectionMeta({ + formatDetection, +}: { + formatDetection: ResolvedMetadata['formatDetection'] +}) { + if (!formatDetection) return null + let content = '' + for (const key of formatDetectionKeys) { + if (formatDetection[key]) { + if (content) content += ', ' + content += `${key}=no` + } + } + return +} + +export function AppleWebAppMeta({ + appleWebApp, +}: { + appleWebApp: ResolvedMetadata['appleWebApp'] +}) { + if (!appleWebApp) return null + const { capable, title, startupImage, statusBarStyle } = appleWebApp + + return ( + <> + {capable ? ( + + ) : null} + {title ? ( + + ) : null} + {startupImage + ? startupImage.map((image, index) => ( + + )) + : null} + {statusBarStyle ? ( + + ) : null} + + ) +} diff --git a/packages/next/src/lib/metadata/metadata.tsx b/packages/next/src/lib/metadata/metadata.tsx index 28e061d796895..549e9f55d6686 100644 --- a/packages/next/src/lib/metadata/metadata.tsx +++ b/packages/next/src/lib/metadata/metadata.tsx @@ -1,7 +1,12 @@ import type { ResolvedMetadata } from './types/metadata-interface' import React from 'react' -import { ResolvedBasicMetadata } from './generate/basic' +import { + AppleWebAppMeta, + FormatDetectionMeta, + ItunesMeta, + ResolvedBasicMetadata, +} from './generate/basic' import { ResolvedAlternatesMetadata } from './generate/alternate' import { ResolvedOpenGraphMetadata } from './generate/opengraph' import { resolveMetadata } from './resolve-metadata' @@ -16,6 +21,9 @@ export async function Metadata({ metadata }: { metadata: any }) { <> + + + diff --git a/packages/next/src/lib/metadata/resolve-metadata.ts b/packages/next/src/lib/metadata/resolve-metadata.ts index ddf34d42636ed..4c40d952b5ece 100644 --- a/packages/next/src/lib/metadata/resolve-metadata.ts +++ b/packages/next/src/lib/metadata/resolve-metadata.ts @@ -25,6 +25,8 @@ const viewPortKeys = { viewportFit: 'viewport-fit', } as const +const invalidKeys = ['apple-touch-fullscreen', 'apple-touch-icon-precomposed'] + type Item = | { type: 'layout' | 'page' @@ -105,6 +107,22 @@ function resolveIcons(icons: Metadata['icons']): ResolvedMetadata['icons'] { return resolved } +function resolveAppleWebApp( + appWebApp: Metadata['appleWebApp'] +): ResolvedMetadata['appleWebApp'] { + if (!appWebApp) return null + const startupImages = resolveAsArrayOrUndefined(appWebApp.startupImage)?.map( + (item) => (typeof item === 'string' ? { url: item } : item) + ) + + return { + capable: 'capable' in appWebApp ? !!appWebApp.capable : true, + title: appWebApp.title || null, + startupImage: startupImages || null, + statusBarStyle: appWebApp.statusBarStyle || 'default', + } +} + // Merge the source metadata into the resolved target metadata. function merge( target: ResolvedMetadata, @@ -158,10 +176,28 @@ function merge( target.icons = resolveIcons(source.icons) break } + case 'appLinks': + case 'verification': + break + case 'appleWebApp': + target.appleWebApp = resolveAppleWebApp(source.appleWebApp) + break + case 'archives': + case 'assets': + case 'bookmarks': + case 'keywords': + target[key] = resolveAsArrayOrUndefined(source[key]) || null + break + case 'authors': + // TODO: improve type infer for resolveAsArrayOrUndefined + target[key] = resolveAsArrayOrUndefined(source[key]) || null + break default: { // TODO: Make sure the type is correct. - // @ts-ignore - target[key] = source[key] + if (!invalidKeys.includes(key)) { + // @ts-ignore + target[key] = source[key] || null + } break } } diff --git a/packages/next/src/lib/metadata/types/extra-types.ts b/packages/next/src/lib/metadata/types/extra-types.ts index 51001e695d5f2..74d86fa350071 100644 --- a/packages/next/src/lib/metadata/types/extra-types.ts +++ b/packages/next/src/lib/metadata/types/extra-types.ts @@ -68,6 +68,13 @@ export type AppleImageDescriptor = { media?: string } +export type ResolvedAppleWebApp = { + capable: boolean + title: string | null + startupImage: AppleImageDescriptor[] | null + statusBarStyle: 'default' | 'black' | 'black-translucent' +} + // Format Detection // This is a poorly specified metadata export type that is supposed to // control whether the device attempts to conver text that matches diff --git a/packages/next/src/lib/metadata/types/metadata-interface.ts b/packages/next/src/lib/metadata/types/metadata-interface.ts index a84de3c64965a..fd7315b0c21fa 100644 --- a/packages/next/src/lib/metadata/types/metadata-interface.ts +++ b/packages/next/src/lib/metadata/types/metadata-interface.ts @@ -7,6 +7,7 @@ import type { AppLinks, FormatDetection, ItunesApp, + ResolvedAppleWebApp, Viewport, } from './extra-types' import type { @@ -18,6 +19,7 @@ import type { IconURL, ReferrerEnum, ResolvedIcons, + ResolvedVerification, Robots, TemplateString, Verification, @@ -38,7 +40,7 @@ export interface Metadata { // Standard metadata names // https://developer.mozilla.org/en-US/docs/Web/HTML/Element/meta/name - applicationName?: null | string | Array + applicationName?: null | string authors?: null | Author | Array generator?: null | string // if you provide an array it will be flattened into a single tag with comma separation @@ -69,7 +71,7 @@ export interface Metadata { // Apple web app metadata // https://developer.apple.com/library/archive/documentation/AppleApplications/Reference/SafariHTMLRef/Articles/MetaTags.html - appleWebApp?: null | boolean | AppleWebApp + appleWebApp?: null | AppleWebApp // Should devices try to interpret various formats and make actionable links // out of them? The canonical example is telephone numbers on mobile that can @@ -154,11 +156,11 @@ export interface ResolvedMetadata { twitter: null | ResolvedTwitterMetadata // common verification tokens - verification: Verification + verification: null | ResolvedVerification // Apple web app metadata // https://developer.apple.com/library/archive/documentation/AppleApplications/Reference/SafariHTMLRef/Articles/MetaTags.html - appleWebApp: null | AppleWebApp + appleWebApp: null | ResolvedAppleWebApp // Should devices try to interpret various formats and make actionable links // out of them? The canonical example is telephone numbers on mobile that can diff --git a/packages/next/src/lib/metadata/types/metadata-types.ts b/packages/next/src/lib/metadata/types/metadata-types.ts index 91196c7184e99..d6f8067004d2a 100644 --- a/packages/next/src/lib/metadata/types/metadata-types.ts +++ b/packages/next/src/lib/metadata/types/metadata-types.ts @@ -93,6 +93,14 @@ export type Verification = { } } +export type ResolvedVerification = { + google?: null | (string | number)[] + yahoo?: null | (string | number)[] + other?: { + [name: string]: (string | number)[] + } +} + export type ResolvedIcons = { icon?: IconDescriptor[] shortcut?: IconDescriptor[] diff --git a/test/e2e/app-dir/metadata/app/apple/page.js b/test/e2e/app-dir/metadata/app/apple/page.js new file mode 100644 index 0000000000000..c437937be9a5f --- /dev/null +++ b/test/e2e/app-dir/metadata/app/apple/page.js @@ -0,0 +1,21 @@ +export default function page() { + return 'apple' +} + +export const metadata = { + itunes: { + appId: 'myAppStoreID', + appArgument: 'myAppArgument', + }, + appleWebApp: { + title: 'Apple Web App', + statusBarStyle: 'black-translucent', + startupImage: [ + '/assets/startup/apple-touch-startup-image-768x1004.png', + { + url: '/assets/startup/apple-touch-startup-image-1536x2008.png', + media: '(device-width: 768px) and (device-height: 1024px)', + }, + ], + }, +} diff --git a/test/e2e/app-dir/metadata/app/basic/page.js b/test/e2e/app-dir/metadata/app/basic/page.js index 224dd10988942..cf0c564de6488 100644 --- a/test/e2e/app-dir/metadata/app/basic/page.js +++ b/test/e2e/app-dir/metadata/app/basic/page.js @@ -22,4 +22,9 @@ export const metadata = { creator: 'shu', publisher: 'vercel', robots: 'index, follow', + formatDetection: { + email: 'no', + address: 'no', + telephone: 'no', + }, } diff --git a/test/e2e/app-dir/metadata/metadata.test.ts b/test/e2e/app-dir/metadata/metadata.test.ts index 4117c9bee6007..008a9c5b4b8e2 100644 --- a/test/e2e/app-dir/metadata/metadata.test.ts +++ b/test/e2e/app-dir/metadata/metadata.test.ts @@ -49,6 +49,9 @@ createNextDescribe( } } + const checkLink = (browser, name, content) => + checkMeta(browser, name, content, 'rel', 'link', 'href') + describe('basic', () => { it('should support title and description', async () => { const browser = await next.browser('/title') @@ -110,6 +113,12 @@ createNextDescribe( await checkMeta(browser, 'creator', 'shu', 'name') await checkMeta(browser, 'publisher', 'vercel', 'name') await checkMeta(browser, 'robots', 'index, follow', 'name') + + await checkMeta( + browser, + 'format-detection', + 'telephone=no, address=no, email=no' + ) }) it('should support object viewport', async () => { @@ -122,6 +131,66 @@ createNextDescribe( ) }) + it('should support apple related tags `itunes` and `appWebApp`', async () => { + const browser = await next.browser('/apple') + + // + // + // + // + // + // + + await checkMeta( + browser, + 'apple-itunes-app', + 'app-id=myAppStoreID, app-argument=myAppArgument', + 'name' + ) + await checkMeta( + browser, + 'apple-mobile-web-app-capable', + 'yes', + 'name' + ) + await checkMeta( + browser, + 'apple-mobile-web-app-title', + 'Apple Web App', + 'name' + ) + await checkMeta( + browser, + 'apple-mobile-web-app-status-bar-style', + 'black-translucent', + 'name' + ) + + expect( + await queryMetaProps( + browser, + 'link', + 'href="/assets/startup/apple-touch-startup-image-768x1004.png"', + ['rel', 'media'] + ) + ).toEqual({ + rel: 'apple-touch-startup-image', + media: null, + }) + + expect( + await queryMetaProps( + browser, + 'link', + 'href="/assets/startup/apple-touch-startup-image-1536x2008.png"', + ['rel', 'media'] + ) + ).toEqual({ + rel: 'apple-touch-startup-image', + media: '(device-width: 768px) and (device-height: 1024px)', + }) + }) + it('should support alternate tags', async () => { const browser = await next.browser('/alternate') await checkMeta( @@ -228,9 +297,6 @@ createNextDescribe( }) describe('icons', () => { - const checkLink = (browser, name, content) => - checkMeta(browser, name, content, 'rel', 'link', 'href') - it('should support basic object icons field', async () => { const browser = await next.browser('/icons') From 1dfa894393063fc62dc1b6f85bff1961eb924a09 Mon Sep 17 00:00:00 2001 From: Jiachi Liu Date: Tue, 24 Jan 2023 22:31:20 +0100 Subject: [PATCH 02/10] fix test --- packages/next/src/lib/metadata/generate/basic.tsx | 2 +- test/e2e/app-dir/metadata/metadata.test.ts | 11 ++--------- 2 files changed, 3 insertions(+), 10 deletions(-) diff --git a/packages/next/src/lib/metadata/generate/basic.tsx b/packages/next/src/lib/metadata/generate/basic.tsx index 84f3687118a45..dedca4ee1f4c5 100644 --- a/packages/next/src/lib/metadata/generate/basic.tsx +++ b/packages/next/src/lib/metadata/generate/basic.tsx @@ -60,7 +60,7 @@ export function ItunesMeta({ itunes }: { itunes: ResolvedMetadata['itunes'] }) { const { appId, appArgument } = itunes let content = `app-id=${appId}` if (appArgument) { - content += ', ' + `app-argument=${appArgument}` + content += `, app-argument=${appArgument}` } return } diff --git a/test/e2e/app-dir/metadata/metadata.test.ts b/test/e2e/app-dir/metadata/metadata.test.ts index 008a9c5b4b8e2..f5aa9410159b4 100644 --- a/test/e2e/app-dir/metadata/metadata.test.ts +++ b/test/e2e/app-dir/metadata/metadata.test.ts @@ -117,7 +117,8 @@ createNextDescribe( await checkMeta( browser, 'format-detection', - 'telephone=no, address=no, email=no' + 'telephone=no, address=no, email=no', + 'name' ) }) @@ -133,14 +134,6 @@ createNextDescribe( it('should support apple related tags `itunes` and `appWebApp`', async () => { const browser = await next.browser('/apple') - - // - // - // - // - // - // - await checkMeta( browser, 'apple-itunes-app', From 0957b4419d2e9d975621f018ce86790c8b170ec6 Mon Sep 17 00:00:00 2001 From: Jiachi Liu Date: Wed, 25 Jan 2023 18:22:45 +0100 Subject: [PATCH 03/10] add twitter and other fields --- .../src/lib/metadata/generate/opengraph.tsx | 48 ++++++++++++++ .../next/src/lib/metadata/generate/utils.ts | 6 +- packages/next/src/lib/metadata/metadata.tsx | 6 +- .../next/src/lib/metadata/resolve-metadata.ts | 63 ++++++++++++++++--- .../src/lib/metadata/types/extra-types.ts | 6 +- .../lib/metadata/types/metadata-interface.ts | 4 +- .../src/lib/metadata/types/twitter-types.ts | 23 +++++-- 7 files changed, 133 insertions(+), 23 deletions(-) diff --git a/packages/next/src/lib/metadata/generate/opengraph.tsx b/packages/next/src/lib/metadata/generate/opengraph.tsx index 4761e002cecb6..bddd1a3b4bb61 100644 --- a/packages/next/src/lib/metadata/generate/opengraph.tsx +++ b/packages/next/src/lib/metadata/generate/opengraph.tsx @@ -221,3 +221,51 @@ export function ResolvedOpenGraphMetadata({ ) } + +export function TwitterMetadata({ + twitter, +}: { + twitter: ResolvedMetadata['twitter'] +}) { + if (!twitter) return null + const { card } = twitter + + return ( + <> + + + + + + + {card === 'player' + ? twitter.players.map((player) => ( + <> + + + + + )) + : null} + {card === 'app' ? ( + <> + + + + + + + + ) : null} + + ) +} diff --git a/packages/next/src/lib/metadata/generate/utils.ts b/packages/next/src/lib/metadata/generate/utils.ts index 39141be4a7e06..629ea94962578 100644 --- a/packages/next/src/lib/metadata/generate/utils.ts +++ b/packages/next/src/lib/metadata/generate/utils.ts @@ -1,6 +1,6 @@ -export function resolveAsArrayOrUndefined( - value: T | T[] | undefined | null -): undefined | T[] { +export function resolveAsArrayOrUndefined< + T extends unknown | readonly unknown[] +>(value: T | T[] | undefined | null): undefined | T[] { if (typeof value === 'undefined' || value === null) { return undefined } diff --git a/packages/next/src/lib/metadata/metadata.tsx b/packages/next/src/lib/metadata/metadata.tsx index 549e9f55d6686..9856625e68dd7 100644 --- a/packages/next/src/lib/metadata/metadata.tsx +++ b/packages/next/src/lib/metadata/metadata.tsx @@ -8,7 +8,10 @@ import { ResolvedBasicMetadata, } from './generate/basic' import { ResolvedAlternatesMetadata } from './generate/alternate' -import { ResolvedOpenGraphMetadata } from './generate/opengraph' +import { + ResolvedOpenGraphMetadata, + TwitterMetadata, +} from './generate/opengraph' import { resolveMetadata } from './resolve-metadata' import { ResolvedIconsMetadata } from './generate/icons' @@ -25,6 +28,7 @@ export async function Metadata({ metadata }: { metadata: any }) { + ) diff --git a/packages/next/src/lib/metadata/resolve-metadata.ts b/packages/next/src/lib/metadata/resolve-metadata.ts index 4c40d952b5ece..234966fd30bdc 100644 --- a/packages/next/src/lib/metadata/resolve-metadata.ts +++ b/packages/next/src/lib/metadata/resolve-metadata.ts @@ -76,6 +76,26 @@ function resolveViewport( return resolved } +function resolveVerification( + verification: Metadata['verification'] +): ResolvedMetadata['verification'] { + const google = resolveAsArrayOrUndefined(verification?.google) + const yahoo = resolveAsArrayOrUndefined(verification?.yahoo) + let other: Record | undefined + if (verification?.other) { + other = {} + for (const key in verification.other) { + const value = resolveAsArrayOrUndefined(verification.other[key]) + if (value) other[key] = value + } + } + return { + google, + yahoo, + other, + } +} + function isUrlIcon(icon: any): icon is string | URL { return typeof icon === 'string' || icon instanceof URL } @@ -111,6 +131,12 @@ function resolveAppleWebApp( appWebApp: Metadata['appleWebApp'] ): ResolvedMetadata['appleWebApp'] { if (!appWebApp) return null + if (appWebApp === true) { + return { + capable: true, + } + } + const startupImages = resolveAsArrayOrUndefined(appWebApp.startupImage)?.map( (item) => (typeof item === 'string' ? { url: item } : item) ) @@ -161,13 +187,21 @@ function merge( } case 'twitter': { if (source.twitter) { + // TODO: improve typing of merging target.twitter = source.twitter as ResolvedTwitterMetadata mergeTitle(target.twitter, templateStrings.twitter) + target.twitter.card = target.twitter.card || 'summary' } else { target.twitter = null } break } + case 'verification': + target.verification = resolveVerification(source.verification) + break + case 'keywords': { + target.keywords = resolveAsArrayOrUndefined(source.keywords) || null + } case 'viewport': { target.viewport = resolveViewport(source.viewport) break @@ -186,20 +220,29 @@ function merge( case 'assets': case 'bookmarks': case 'keywords': + case 'authors': { + // FIXME: type inferring + // @ts-ignore target[key] = resolveAsArrayOrUndefined(source[key]) || null break - case 'authors': - // TODO: improve type infer for resolveAsArrayOrUndefined - target[key] = resolveAsArrayOrUndefined(source[key]) || null + } + // directly assign fields that fallback to null + case 'applicationName': + case 'description': + case 'generator': + case 'themeColor': + case 'creator': + case 'publisher': + case 'referrer': + case 'colorScheme': + // TODO: support inferring + // @ts-ignore + target[key] = source[key] || null break - default: { - // TODO: Make sure the type is correct. - if (!invalidKeys.includes(key)) { - // @ts-ignore - target[key] = source[key] || null - } + // TODO: support more fields + // case 'robots': + default: break - } } } } diff --git a/packages/next/src/lib/metadata/types/extra-types.ts b/packages/next/src/lib/metadata/types/extra-types.ts index 74d86fa350071..8c804e709b766 100644 --- a/packages/next/src/lib/metadata/types/extra-types.ts +++ b/packages/next/src/lib/metadata/types/extra-types.ts @@ -70,9 +70,9 @@ export type AppleImageDescriptor = { export type ResolvedAppleWebApp = { capable: boolean - title: string | null - startupImage: AppleImageDescriptor[] | null - statusBarStyle: 'default' | 'black' | 'black-translucent' + title?: string | null + startupImage?: AppleImageDescriptor[] | null + statusBarStyle?: 'default' | 'black' | 'black-translucent' } // Format Detection diff --git a/packages/next/src/lib/metadata/types/metadata-interface.ts b/packages/next/src/lib/metadata/types/metadata-interface.ts index fd7315b0c21fa..822b942f4f4f6 100644 --- a/packages/next/src/lib/metadata/types/metadata-interface.ts +++ b/packages/next/src/lib/metadata/types/metadata-interface.ts @@ -56,7 +56,7 @@ export interface Metadata { robots?: null | string | Robots // The canonical and alternate URLs for this location - alternates: AlternateURLs + alternates?: null | AlternateURLs // Defaults to rel="icon" but the Icons type can be used // to get more specific about rel types @@ -71,7 +71,7 @@ export interface Metadata { // Apple web app metadata // https://developer.apple.com/library/archive/documentation/AppleApplications/Reference/SafariHTMLRef/Articles/MetaTags.html - appleWebApp?: null | AppleWebApp + appleWebApp?: null | boolean | AppleWebApp // Should devices try to interpret various formats and make actionable links // out of them? The canonical example is telephone numbers on mobile that can diff --git a/packages/next/src/lib/metadata/types/twitter-types.ts b/packages/next/src/lib/metadata/types/twitter-types.ts index f0666d9f64ebd..d6ddb446ef6bf 100644 --- a/packages/next/src/lib/metadata/types/twitter-types.ts +++ b/packages/next/src/lib/metadata/types/twitter-types.ts @@ -55,12 +55,27 @@ type TwitterImageDescriptor = { height?: string | number } type TwitterPlayerDescriptor = { - playerUrl: string | URL - streamUrl: string | URL + url: string | URL width: number height: number } -export type ResolvedTwitterMetadata = Omit & { - title: AbsoluteTemplateString | null +type ResolvedTwitterSummary = { + site: string + siteId: string + creator: string + creatorId: string + description: string + title: AbsoluteTemplateString + images: Array +} +type ResolvedTwitterPlayer = ResolvedTwitterSummary & { + players: Array } +type ResolvedTwitterApp = ResolvedTwitterSummary & { app: TwitterAppDescriptor } + +export type ResolvedTwitterMetadata = + | ({ card: 'summary' } & ResolvedTwitterSummary) + | ({ card: 'summary_large_image' } & ResolvedTwitterSummary) + | ({ card: 'player' } & ResolvedTwitterPlayer) + | ({ card: 'app' } & ResolvedTwitterApp) From 8125b9d6f66c070e85569da87eb6a33cdb6203be Mon Sep 17 00:00:00 2001 From: Jiachi Liu Date: Wed, 25 Jan 2023 19:43:16 +0100 Subject: [PATCH 04/10] add tests --- .../next/src/lib/metadata/generate/basic.tsx | 18 ++-- .../src/lib/metadata/generate/opengraph.tsx | 53 +++++++---- .../next/src/lib/metadata/resolve-metadata.ts | 48 ++++++++-- .../lib/metadata/types/metadata-interface.ts | 2 +- .../src/lib/metadata/types/twitter-types.ts | 21 +++-- .../app-dir/metadata/app/twitter/app/page.js | 29 ++++++ .../metadata/app/twitter/large-image/page.js | 15 +++ test/e2e/app-dir/metadata/app/twitter/page.js | 14 +++ .../metadata/app/twitter/player/page.js | 21 +++++ test/e2e/app-dir/metadata/metadata.test.ts | 93 +++++++++++++++++++ 10 files changed, 269 insertions(+), 45 deletions(-) create mode 100644 test/e2e/app-dir/metadata/app/twitter/app/page.js create mode 100644 test/e2e/app-dir/metadata/app/twitter/large-image/page.js create mode 100644 test/e2e/app-dir/metadata/app/twitter/page.js create mode 100644 test/e2e/app-dir/metadata/app/twitter/player/page.js diff --git a/packages/next/src/lib/metadata/generate/basic.tsx b/packages/next/src/lib/metadata/generate/basic.tsx index dedca4ee1f4c5..9119e919957f7 100644 --- a/packages/next/src/lib/metadata/generate/basic.tsx +++ b/packages/next/src/lib/metadata/generate/basic.tsx @@ -1,7 +1,7 @@ import type { ResolvedMetadata } from '../types/metadata-interface' import React from 'react' -import { Meta } from './meta' +import { Meta, MultiMeta } from './meta' export function ResolvedBasicMetadata({ metadata, @@ -44,13 +44,15 @@ export function ResolvedBasicMetadata({ : null} - {Object.entries(metadata.other).map(([name, content]) => ( - - ))} + {metadata.other + ? Object.entries(metadata.other).map(([name, content]) => ( + + )) + : null} ) } diff --git a/packages/next/src/lib/metadata/generate/opengraph.tsx b/packages/next/src/lib/metadata/generate/opengraph.tsx index bddd1a3b4bb61..ab8b53a6972b8 100644 --- a/packages/next/src/lib/metadata/generate/opengraph.tsx +++ b/packages/next/src/lib/metadata/generate/opengraph.tsx @@ -2,6 +2,7 @@ import type { ResolvedMetadata } from '../types/metadata-interface' import React from 'react' import { Meta, MultiMeta } from './meta' +import { TwitterAppDescriptor } from '../types/twitter-types' export function ResolvedOpenGraphMetadata({ openGraph, @@ -222,6 +223,22 @@ export function ResolvedOpenGraphMetadata({ ) } +function TwitterAppItem({ + app, + type, +}: { + app: TwitterAppDescriptor + type: 'iphone' | 'ipad' | 'googleplay' +}) { + return ( + <> + + + + + ) +} + export function TwitterMetadata({ twitter, }: { @@ -234,14 +251,22 @@ export function TwitterMetadata({ <> - - - - + + + + + {card === 'player' ? twitter.players.map((player) => ( <> - + + @@ -249,21 +274,9 @@ export function TwitterMetadata({ : null} {card === 'app' ? ( <> - - - - - - + + + ) : null} diff --git a/packages/next/src/lib/metadata/resolve-metadata.ts b/packages/next/src/lib/metadata/resolve-metadata.ts index 234966fd30bdc..c9422960e1f64 100644 --- a/packages/next/src/lib/metadata/resolve-metadata.ts +++ b/packages/next/src/lib/metadata/resolve-metadata.ts @@ -107,6 +107,13 @@ function resolveIcon(icon: Icon): IconDescriptor { } const IconKeys = ['icon', 'shortcut', 'apple', 'other'] as (keyof Icons)[] +const TwitterBasicInfoKeys = [ + 'site', + 'siteId', + 'creator', + 'creatorId', + 'description', +] as const function resolveIcons(icons: Metadata['icons']): ResolvedMetadata['icons'] { if (!icons) { @@ -163,10 +170,6 @@ function merge( const key = key_ as keyof Metadata switch (key) { - case 'other': { - Object.assign(target.other, source.other) - break - } case 'title': { if (source.title) { target.title = source.title as AbsoluteTemplateString @@ -188,9 +191,37 @@ function merge( case 'twitter': { if (source.twitter) { // TODO: improve typing of merging - target.twitter = source.twitter as ResolvedTwitterMetadata - mergeTitle(target.twitter, templateStrings.twitter) - target.twitter.card = target.twitter.card || 'summary' + const resolved = { + title: source.twitter.title, + } as ResolvedTwitterMetadata + for (const key of TwitterBasicInfoKeys) { + resolved[key] = source.twitter[key] || null + } + mergeTitle(resolved, templateStrings.twitter) + resolved.images = + resolveAsArrayOrUndefined(source.twitter.images) || [] + if ('card' in source.twitter) { + resolved.card = source.twitter.card + switch (source.twitter.card) { + case 'player': { + // @ts-ignore + resolved.players = + resolveAsArrayOrUndefined(source.twitter.players) || [] + break + } + case 'app': { + // @ts-ignore + resolved.app = source.twitter.app || {} + break + } + default: + break + } + } else { + resolved.card = 'summary' + } + + target.twitter = resolved } else { target.twitter = null } @@ -233,8 +264,11 @@ function merge( case 'themeColor': case 'creator': case 'publisher': + case 'category': + case 'classification': case 'referrer': case 'colorScheme': + case 'other': // TODO: support inferring // @ts-ignore target[key] = source[key] || null diff --git a/packages/next/src/lib/metadata/types/metadata-interface.ts b/packages/next/src/lib/metadata/types/metadata-interface.ts index 822b942f4f4f6..f4a74232e8740 100644 --- a/packages/next/src/lib/metadata/types/metadata-interface.ts +++ b/packages/next/src/lib/metadata/types/metadata-interface.ts @@ -189,7 +189,7 @@ export interface ResolvedMetadata { classification: null | string // Arbitrary name/value pairs - other: { + other: null | { [name: string]: string | number | Array } diff --git a/packages/next/src/lib/metadata/types/twitter-types.ts b/packages/next/src/lib/metadata/types/twitter-types.ts index d6ddb446ef6bf..e1238c4c68293 100644 --- a/packages/next/src/lib/metadata/types/twitter-types.ts +++ b/packages/next/src/lib/metadata/types/twitter-types.ts @@ -1,3 +1,5 @@ +// Reference: https://developer.twitter.com/en/docs/twitter-for-websites/cards/overview/markup + import type { AbsoluteTemplateString, TemplateString } from './metadata-types' export type Twitter = @@ -13,8 +15,8 @@ type TwitterMetadata = { siteId?: string // id for account associated to the site itself creator?: string // username for the account associated to the creator of the content on the site creatorId?: string // id for the account associated to the creator of the content on the site - title?: string | TemplateString description?: string + title?: string | TemplateString images?: TwitterImage | Array } type TwitterSummary = TwitterMetadata & { @@ -31,7 +33,7 @@ type TwitterApp = TwitterMetadata & { card: 'app' app: TwitterAppDescriptor } -type TwitterAppDescriptor = { +export type TwitterAppDescriptor = { id: { iphone?: string | number ipad?: string | number @@ -42,7 +44,7 @@ type TwitterAppDescriptor = { ipad?: string | URL googleplay?: string | URL } - country?: string + name?: string } type TwitterImage = string | TwitterImageDescriptor | URL @@ -55,17 +57,18 @@ type TwitterImageDescriptor = { height?: string | number } type TwitterPlayerDescriptor = { - url: string | URL + playerUrl: string | URL + streamUrl: string | URL width: number height: number } type ResolvedTwitterSummary = { - site: string - siteId: string - creator: string - creatorId: string - description: string + site: string | null + siteId: string | null + creator: string | null + creatorId: string | null + description: string | null title: AbsoluteTemplateString images: Array } diff --git a/test/e2e/app-dir/metadata/app/twitter/app/page.js b/test/e2e/app-dir/metadata/app/twitter/app/page.js new file mode 100644 index 0000000000000..4b2ffa6a890f0 --- /dev/null +++ b/test/e2e/app-dir/metadata/app/twitter/app/page.js @@ -0,0 +1,29 @@ +export default function page() { + return 'twitter app' +} + +export const metadata = { + twitter: { + card: 'app', + title: 'Twitter Title', + description: 'Twitter Description', + siteId: 'siteId', + creator: 'creator', + creatorId: 'creatorId', + images: [ + 'https://twitter.com/image-100x100.png', + 'https://twitter.com/image-200x200.png', + ], + app: { + name: 'twitter_app', + id: { + iphone: 'twitter_app://iphone', + ipad: 'twitter_app://iphone', + }, + url: { + iphone: 'twitter_app://iphone', + ipad: 'twitter_app://iphone', + }, + }, + }, +} diff --git a/test/e2e/app-dir/metadata/app/twitter/large-image/page.js b/test/e2e/app-dir/metadata/app/twitter/large-image/page.js new file mode 100644 index 0000000000000..13ddd7b4a0181 --- /dev/null +++ b/test/e2e/app-dir/metadata/app/twitter/large-image/page.js @@ -0,0 +1,15 @@ +export default function page() { + return 'twitter summary_large_image' +} + +export const metadata = { + twitter: { + card: 'summary_large_image', + title: 'Twitter Title', + description: 'Twitter Description', + siteId: 'siteId', + creator: 'creator', + creatorId: 'creatorId', + images: 'https://twitter.com/image.png', + }, +} diff --git a/test/e2e/app-dir/metadata/app/twitter/page.js b/test/e2e/app-dir/metadata/app/twitter/page.js new file mode 100644 index 0000000000000..1271e79a229c8 --- /dev/null +++ b/test/e2e/app-dir/metadata/app/twitter/page.js @@ -0,0 +1,14 @@ +export default function page() { + return 'twitter summary' +} + +export const metadata = { + twitter: { + title: 'Twitter Title', + description: 'Twitter Description', + siteId: 'siteId', + creator: 'creator', + creatorId: 'creatorId', + images: 'https://twitter.com/image.png', + }, +} diff --git a/test/e2e/app-dir/metadata/app/twitter/player/page.js b/test/e2e/app-dir/metadata/app/twitter/player/page.js new file mode 100644 index 0000000000000..5b7b065d0b456 --- /dev/null +++ b/test/e2e/app-dir/metadata/app/twitter/player/page.js @@ -0,0 +1,21 @@ +export default function page() { + return 'twitter player' +} + +export const metadata = { + twitter: { + card: 'player', + title: 'Twitter Title', + description: 'Twitter Description', + siteId: 'siteId', + creator: 'creator', + creatorId: 'creatorId', + images: 'https://twitter.com/image.png', + players: { + playerUrl: 'https://twitter.com/player', + streamUrl: 'https://twitter.com/stream', + width: 100, + height: 100, + }, + }, +} diff --git a/test/e2e/app-dir/metadata/metadata.test.ts b/test/e2e/app-dir/metadata/metadata.test.ts index f5aa9410159b4..05344703e484c 100644 --- a/test/e2e/app-dir/metadata/metadata.test.ts +++ b/test/e2e/app-dir/metadata/metadata.test.ts @@ -335,6 +335,99 @@ createNextDescribe( ).toEqual({ sizes: '180x180', type: 'image/png' }) }) }) + + describe('twitter', () => { + it('should support default twitter summary card', async () => { + const browser = await next.browser('/twitter') + const expected = { + title: 'Twitter Title', + description: 'Twitter Description', + siteId: 'siteId', + creator: 'creator', + 'creator:id': 'creatorId', + image: 'https://twitter.com/image.png', + card: 'summary', + } + + await Promise.all( + Object.keys(expected).map(async (key) => { + await checkMeta(browser, `twitter:${key}`, expected[key], 'name') + }) + ) + }) + + it('should support default twitter summary_large_image card', async () => { + const browser = await next.browser('/twitter/large-image') + const expected = { + title: 'Twitter Title', + description: 'Twitter Description', + siteId: 'siteId', + creator: 'creator', + 'creator:id': 'creatorId', + image: 'https://twitter.com/image.png', + card: 'summary_large_image', + } + + await Promise.all( + Object.keys(expected).map(async (key) => { + await checkMeta(browser, `twitter:${key}`, expected[key], 'name') + }) + ) + }) + + it('should support default twitter player card', async () => { + const browser = await next.browser('/twitter/player') + const expected = { + title: 'Twitter Title', + description: 'Twitter Description', + siteId: 'siteId', + creator: 'creator', + 'creator:id': 'creatorId', + image: 'https://twitter.com/image.png', + // player properties + card: 'player', + player: 'https://twitter.com/player', + 'player:stream': 'https://twitter.com/stream', + 'player:width': '100', + 'player:height': '100', + } + + await Promise.all( + Object.keys(expected).map(async (key) => { + await checkMeta(browser, `twitter:${key}`, expected[key], 'name') + }) + ) + }) + + it('should support default twitter app card', async () => { + const browser = await next.browser('/twitter/app') + const expected = { + title: 'Twitter Title', + description: 'Twitter Description', + siteId: 'siteId', + creator: 'creator', + 'creator:id': 'creatorId', + image: [ + 'https://twitter.com/image-100x100.png', + 'https://twitter.com/image-200x200.png', + ], + // app properties + card: 'app', + 'app:id:iphone': 'twitter_app://iphone', + 'app:url:iphone': 'twitter_app://iphone', + 'app:id:ipad': 'twitter_app://iphone', + 'app:url:ipad': 'twitter_app://iphone', + 'app:id:googleplay': 'twitter_app://iphone', + 'app:url:googleplay': 'twitter_app://iphone', + } + + await Promise.all( + Object.keys(expected).map(async (key) => { + await checkMeta(browser, `twitter:${key}`, expected[key], 'name') + }) + ) + }) + }) }) } ) From ad7eb1bf44162aa744361f3e913fbaaa26f7b2ea Mon Sep 17 00:00:00 2001 From: Jiachi Liu Date: Wed, 25 Jan 2023 21:08:15 +0100 Subject: [PATCH 05/10] update tests --- .../src/lib/metadata/generate/alternate.tsx | 21 +- .../next/src/lib/metadata/generate/basic.tsx | 8 +- .../next/src/lib/metadata/generate/icons.tsx | 6 +- .../src/lib/metadata/generate/opengraph.tsx | 3 +- packages/next/src/lib/metadata/metadata.tsx | 19 +- .../next/src/lib/metadata/resolve-metadata.ts | 15 +- .../lib/metadata/types/metadata-interface.ts | 2 +- test/e2e/app-dir/metadata/metadata.test.ts | 202 +++++++++++------- 8 files changed, 159 insertions(+), 117 deletions(-) diff --git a/packages/next/src/lib/metadata/generate/alternate.tsx b/packages/next/src/lib/metadata/generate/alternate.tsx index 4bd4eddd148b8..f6b10cf40860c 100644 --- a/packages/next/src/lib/metadata/generate/alternate.tsx +++ b/packages/next/src/lib/metadata/generate/alternate.tsx @@ -2,17 +2,18 @@ import type { ResolvedMetadata } from '../types/metadata-interface' import React from 'react' -export function ResolvedAlternatesMetadata({ - metadata, +export function AlternatesMetadata({ + alternates, }: { - metadata: ResolvedMetadata + alternates: ResolvedMetadata['alternates'] }) { + if (!alternates) return null return ( <> - {metadata.alternates.canonical ? ( - + {alternates.canonical ? ( + ) : null} - {Object.entries(metadata.alternates.languages).map(([locale, url]) => + {Object.entries(alternates.languages).map(([locale, url]) => url ? ( ) : null )} - {metadata.alternates.media - ? Object.entries(metadata.alternates.media).map(([media, url]) => + {alternates.media + ? Object.entries(alternates.media).map(([media, url]) => url ? ( + {alternates.types + ? Object.entries(alternates.types).map(([type, url]) => url ? ( diff --git a/packages/next/src/lib/metadata/generate/icons.tsx b/packages/next/src/lib/metadata/generate/icons.tsx index 76260a0a97ab1..b94034863bd4e 100644 --- a/packages/next/src/lib/metadata/generate/icons.tsx +++ b/packages/next/src/lib/metadata/generate/icons.tsx @@ -22,11 +22,7 @@ function IconLink({ rel, icon }: { rel?: string; icon: Icon }) { } } -export function ResolvedIconsMetadata({ - icons, -}: { - icons: ResolvedMetadata['icons'] -}) { +export function IconsMetadata({ icons }: { icons: ResolvedMetadata['icons'] }) { if (!icons) return null const shortcutList = icons.shortcut diff --git a/packages/next/src/lib/metadata/generate/opengraph.tsx b/packages/next/src/lib/metadata/generate/opengraph.tsx index ab8b53a6972b8..5f7d7792a0e31 100644 --- a/packages/next/src/lib/metadata/generate/opengraph.tsx +++ b/packages/next/src/lib/metadata/generate/opengraph.tsx @@ -4,7 +4,7 @@ import React from 'react' import { Meta, MultiMeta } from './meta' import { TwitterAppDescriptor } from '../types/twitter-types' -export function ResolvedOpenGraphMetadata({ +export function OpenGraphMetadata({ openGraph, }: { openGraph: ResolvedMetadata['openGraph'] @@ -251,6 +251,7 @@ export function TwitterMetadata({ <> + diff --git a/packages/next/src/lib/metadata/metadata.tsx b/packages/next/src/lib/metadata/metadata.tsx index 9856625e68dd7..27950d3ae3e1f 100644 --- a/packages/next/src/lib/metadata/metadata.tsx +++ b/packages/next/src/lib/metadata/metadata.tsx @@ -5,15 +5,12 @@ import { AppleWebAppMeta, FormatDetectionMeta, ItunesMeta, - ResolvedBasicMetadata, + BasicMetadata, } from './generate/basic' -import { ResolvedAlternatesMetadata } from './generate/alternate' -import { - ResolvedOpenGraphMetadata, - TwitterMetadata, -} from './generate/opengraph' +import { AlternatesMetadata } from './generate/alternate' +import { OpenGraphMetadata, TwitterMetadata } from './generate/opengraph' import { resolveMetadata } from './resolve-metadata' -import { ResolvedIconsMetadata } from './generate/icons' +import { IconsMetadata } from './generate/icons' // Generate the actual React elements from the resolved metadata. export async function Metadata({ metadata }: { metadata: any }) { @@ -22,14 +19,14 @@ export async function Metadata({ metadata }: { metadata: any }) { const resolved: ResolvedMetadata = await resolveMetadata(metadata) return ( <> - - + + - + - + ) } diff --git a/packages/next/src/lib/metadata/resolve-metadata.ts b/packages/next/src/lib/metadata/resolve-metadata.ts index c9422960e1f64..0dd4c04cca838 100644 --- a/packages/next/src/lib/metadata/resolve-metadata.ts +++ b/packages/next/src/lib/metadata/resolve-metadata.ts @@ -25,8 +25,6 @@ const viewPortKeys = { viewportFit: 'viewport-fit', } as const -const invalidKeys = ['apple-touch-fullscreen', 'apple-touch-icon-precomposed'] - type Item = | { type: 'layout' | 'page' @@ -268,13 +266,20 @@ function merge( case 'classification': case 'referrer': case 'colorScheme': + case 'itunes': + case 'alternates': + case 'formatDetection': case 'other': - // TODO: support inferring - // @ts-ignore + // @ts-ignore TODO: support inferring target[key] = source[key] || null break // TODO: support more fields - // case 'robots': + case 'robots': { + // TODO: resolve robots + if (typeof source.robots === 'string') { + target.robots = source.robots + } + } default: break } diff --git a/packages/next/src/lib/metadata/types/metadata-interface.ts b/packages/next/src/lib/metadata/types/metadata-interface.ts index f4a74232e8740..251b9450c2cb2 100644 --- a/packages/next/src/lib/metadata/types/metadata-interface.ts +++ b/packages/next/src/lib/metadata/types/metadata-interface.ts @@ -145,7 +145,7 @@ export interface ResolvedMetadata { robots: null | string // The canonical and alternate URLs for this location - alternates: ResolvedAlternateURLs + alternates: null | ResolvedAlternateURLs // Defaults to rel="icon" but the Icons type can be used // to get more specific about rel types diff --git a/test/e2e/app-dir/metadata/metadata.test.ts b/test/e2e/app-dir/metadata/metadata.test.ts index 05344703e484c..e3b2bda263f37 100644 --- a/test/e2e/app-dir/metadata/metadata.test.ts +++ b/test/e2e/app-dir/metadata/metadata.test.ts @@ -45,12 +45,26 @@ createNextDescribe( if (Array.isArray(content)) { expect(values).toEqual(content) } else { - expect(values[0]).toContain(content) + expect(values[0]).toBe(content) } } - const checkLink = (browser, name, content) => - checkMeta(browser, name, content, 'rel', 'link', 'href') + const checkMetaPropertyContentPair = ( + browser: BrowserInterface, + name: string, + content: string | string[] + ) => checkMeta(browser, name, content, 'property') + const checkMetaNameContentPair = ( + browser: BrowserInterface, + name: string, + content: string | string[] + ) => checkMeta(browser, name, content, 'name') + + const checkLink = ( + browser: BrowserInterface, + name: string, + content: string | string[] + ) => checkMeta(browser, name, content, 'rel', 'link', 'href') describe('basic', () => { it('should support title and description', async () => { @@ -58,11 +72,10 @@ createNextDescribe( expect(await browser.eval(`document.title`)).toBe( 'this is the page title' ) - await checkMeta( + await checkMetaNameContentPair( browser, 'description', - 'this is the layout description', - 'name' + 'this is the layout description' ) }) @@ -87,76 +100,67 @@ createNextDescribe( it('should support other basic tags', async () => { const browser = await next.browser('/basic') - await checkMeta(browser, 'generator', 'next.js', 'name') - await checkMeta(browser, 'application-name', 'test', 'name') - await checkMeta( + await checkMetaNameContentPair(browser, 'generator', 'next.js') + await checkMetaNameContentPair(browser, 'application-name', 'test') + await checkMetaNameContentPair( browser, 'referrer', - 'origin-when-crossorigin', - 'name' + 'origin-when-crossorigin' ) - await checkMeta( + await checkMetaNameContentPair( browser, 'keywords', - 'next.js,react,javascript', - 'name' + 'next.js,react,javascript' ) - await checkMeta(browser, 'author', 'John Doe,Jane Doe', 'name') - await checkMeta(browser, 'theme-color', 'cyan', 'name') - await checkMeta(browser, 'color-scheme', 'dark', 'name') - await checkMeta( + await checkMetaNameContentPair(browser, 'author', 'John Doe,Jane Doe') + await checkMetaNameContentPair(browser, 'theme-color', 'cyan') + await checkMetaNameContentPair(browser, 'color-scheme', 'dark') + await checkMetaNameContentPair( browser, 'viewport', - 'width=device-width, initial-scale=1, shrink-to-fit=no', - 'name' + 'width=device-width, initial-scale=1, shrink-to-fit=no' ) - await checkMeta(browser, 'creator', 'shu', 'name') - await checkMeta(browser, 'publisher', 'vercel', 'name') - await checkMeta(browser, 'robots', 'index, follow', 'name') + await checkMetaNameContentPair(browser, 'creator', 'shu') + await checkMetaNameContentPair(browser, 'publisher', 'vercel') + await checkMetaNameContentPair(browser, 'robots', 'index, follow') - await checkMeta( + await checkMetaNameContentPair( browser, 'format-detection', - 'telephone=no, address=no, email=no', - 'name' + 'telephone=no, address=no, email=no' ) }) it('should support object viewport', async () => { const browser = await next.browser('/viewport/object') - await checkMeta( + await checkMetaNameContentPair( browser, 'viewport', - 'width=device-width, initial-scale=1, maximum-scale=1', - 'name' + 'width=device-width, initial-scale=1, maximum-scale=1' ) }) it('should support apple related tags `itunes` and `appWebApp`', async () => { const browser = await next.browser('/apple') - await checkMeta( + await checkMetaNameContentPair( browser, 'apple-itunes-app', - 'app-id=myAppStoreID, app-argument=myAppArgument', - 'name' + 'app-id=myAppStoreID, app-argument=myAppArgument' ) - await checkMeta( + await checkMetaNameContentPair( browser, 'apple-mobile-web-app-capable', - 'yes', - 'name' + 'yes' ) - await checkMeta( + await checkMetaNameContentPair( browser, 'apple-mobile-web-app-title', - 'Apple Web App', - 'name' + 'Apple Web App' ) - await checkMeta( + await checkMetaNameContentPair( browser, 'apple-mobile-web-app-status-bar-style', - 'black-translucent', - 'name' + 'black-translucent' ) expect( @@ -186,14 +190,7 @@ createNextDescribe( it('should support alternate tags', async () => { const browser = await next.browser('/alternate') - await checkMeta( - browser, - 'canonical', - 'https://example.com', - 'rel', - 'link', - 'href' - ) + await checkLink(browser, 'canonical', 'https://example.com') await checkMeta( browser, 'en-US', @@ -237,11 +234,10 @@ createNextDescribe( .click() .waitForElementByCss('#basic', 2000) - await checkMeta( + await checkMetaNameContentPair( browser, 'referrer', - 'origin-when-crossorigin', - 'name' + 'origin-when-crossorigin' ) await browser.back().waitForElementByCss('#index', 2000) expect(await getTitle(browser)).toBe('index page') @@ -256,32 +252,66 @@ createNextDescribe( describe('opengraph', () => { it('should support opengraph tags', async () => { const browser = await next.browser('/opengraph') - await checkMeta(browser, 'og:title', 'My custom title') - await checkMeta(browser, 'og:description', 'My custom description') - await checkMeta(browser, 'og:url', 'https://example.com') - await checkMeta(browser, 'og:site_name', 'My custom site name') - await checkMeta(browser, 'og:locale', 'en-US') - await checkMeta(browser, 'og:type', 'website') - await checkMeta(browser, 'og:image:url', [ + await checkMetaPropertyContentPair( + browser, + 'og:title', + 'My custom title' + ) + await checkMetaPropertyContentPair( + browser, + 'og:description', + 'My custom description' + ) + await checkMetaPropertyContentPair( + browser, + 'og:url', + 'https://example.com/' + ) + await checkMetaPropertyContentPair( + browser, + 'og:site_name', + 'My custom site name' + ) + await checkMetaPropertyContentPair(browser, 'og:locale', 'en-US') + await checkMetaPropertyContentPair(browser, 'og:type', 'website') + await checkMetaPropertyContentPair(browser, 'og:image:url', [ 'https://example.com/image.png', 'https://example.com/image2.png', ]) - await checkMeta(browser, 'og:image:width', ['800', '1800']) - await checkMeta(browser, 'og:image:height', ['600', '1600']) - await checkMeta(browser, 'og:image:alt', 'My custom alt') + await checkMetaPropertyContentPair(browser, 'og:image:width', [ + '800', + '1800', + ]) + await checkMetaPropertyContentPair(browser, 'og:image:height', [ + '600', + '1600', + ]) + await checkMetaPropertyContentPair( + browser, + 'og:image:alt', + 'My custom alt' + ) }) it('should support opengraph with article type', async () => { const browser = await next.browser('/opengraph/article') - await checkMeta(browser, 'og:title', 'My custom title') - await checkMeta(browser, 'og:description', 'My custom description') - await checkMeta(browser, 'og:type', 'article') - await checkMeta( + await checkMetaPropertyContentPair( + browser, + 'og:title', + 'My custom title' + ) + await checkMetaPropertyContentPair( + browser, + 'og:description', + 'My custom description' + ) + await checkMetaPropertyContentPair(browser, 'og:type', 'article') + await checkMetaPropertyContentPair( browser, 'article:published_time', '2023-01-01T00:00:00.000Z' ) - await checkMeta(browser, 'article:author', [ + await checkMetaPropertyContentPair(browser, 'article:author', [ 'author1', 'author2', 'author3', @@ -342,7 +372,7 @@ createNextDescribe( const expected = { title: 'Twitter Title', description: 'Twitter Description', - siteId: 'siteId', + 'site:id': 'siteId', creator: 'creator', 'creator:id': 'creatorId', image: 'https://twitter.com/image.png', @@ -351,7 +381,11 @@ createNextDescribe( await Promise.all( Object.keys(expected).map(async (key) => { - await checkMeta(browser, `twitter:${key}`, expected[key], 'name') + return checkMetaNameContentPair( + browser, + `twitter:${key}`, + expected[key] + ) }) ) }) @@ -361,7 +395,7 @@ createNextDescribe( const expected = { title: 'Twitter Title', description: 'Twitter Description', - siteId: 'siteId', + 'site:id': 'siteId', creator: 'creator', 'creator:id': 'creatorId', image: 'https://twitter.com/image.png', @@ -369,8 +403,12 @@ createNextDescribe( } await Promise.all( - Object.keys(expected).map(async (key) => { - await checkMeta(browser, `twitter:${key}`, expected[key], 'name') + Object.keys(expected).map((key) => { + return checkMetaNameContentPair( + browser, + `twitter:${key}`, + expected[key] + ) }) ) }) @@ -380,7 +418,7 @@ createNextDescribe( const expected = { title: 'Twitter Title', description: 'Twitter Description', - siteId: 'siteId', + 'site:id': 'siteId', creator: 'creator', 'creator:id': 'creatorId', image: 'https://twitter.com/image.png', @@ -393,8 +431,12 @@ createNextDescribe( } await Promise.all( - Object.keys(expected).map(async (key) => { - await checkMeta(browser, `twitter:${key}`, expected[key], 'name') + Object.keys(expected).map((key) => { + return checkMetaNameContentPair( + browser, + `twitter:${key}`, + expected[key] + ) }) ) }) @@ -404,7 +446,7 @@ createNextDescribe( const expected = { title: 'Twitter Title', description: 'Twitter Description', - siteId: 'siteId', + 'site:id': 'siteId', creator: 'creator', 'creator:id': 'creatorId', image: [ @@ -422,8 +464,12 @@ createNextDescribe( } await Promise.all( - Object.keys(expected).map(async (key) => { - await checkMeta(browser, `twitter:${key}`, expected[key], 'name') + Object.keys(expected).map((key) => { + return checkMetaNameContentPair( + browser, + `twitter:${key}`, + expected[key] + ) }) ) }) From 2739d1cc295cef52b339b5f519e5378e6277ead2 Mon Sep 17 00:00:00 2001 From: Jiachi Liu Date: Wed, 25 Jan 2023 21:16:38 +0100 Subject: [PATCH 06/10] fix lint --- packages/next/src/lib/metadata/resolve-metadata.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/packages/next/src/lib/metadata/resolve-metadata.ts b/packages/next/src/lib/metadata/resolve-metadata.ts index 0dd4c04cca838..5c084185aca67 100644 --- a/packages/next/src/lib/metadata/resolve-metadata.ts +++ b/packages/next/src/lib/metadata/resolve-metadata.ts @@ -192,8 +192,8 @@ function merge( const resolved = { title: source.twitter.title, } as ResolvedTwitterMetadata - for (const key of TwitterBasicInfoKeys) { - resolved[key] = source.twitter[key] || null + for (const infoKey of TwitterBasicInfoKeys) { + resolved[infoKey] = source.twitter[infoKey] || null } mergeTitle(resolved, templateStrings.twitter) resolved.images = @@ -228,9 +228,6 @@ function merge( case 'verification': target.verification = resolveVerification(source.verification) break - case 'keywords': { - target.keywords = resolveAsArrayOrUndefined(source.keywords) || null - } case 'viewport': { target.viewport = resolveViewport(source.viewport) break @@ -279,6 +276,7 @@ function merge( if (typeof source.robots === 'string') { target.robots = source.robots } + break } default: break From 969b776ee681fa4978b0027a5be4083d36306a84 Mon Sep 17 00:00:00 2001 From: Jiachi Liu Date: Thu, 26 Jan 2023 17:00:05 +0100 Subject: [PATCH 07/10] robots and app links --- .../next/src/lib/metadata/generate/basic.tsx | 3 +- .../next/src/lib/metadata/generate/meta.tsx | 65 +++++--- .../src/lib/metadata/generate/opengraph.tsx | 34 ++++- packages/next/src/lib/metadata/metadata.tsx | 7 +- .../next/src/lib/metadata/resolve-metadata.ts | 143 +++++++++++------- .../src/lib/metadata/types/extra-types.ts | 10 ++ .../lib/metadata/types/metadata-interface.ts | 6 +- .../src/lib/metadata/types/metadata-types.ts | 12 +- packages/next/src/server/app-render.tsx | 11 +- .../app-dir/metadata/app/app-links/page.js | 19 +++ test/e2e/app-dir/metadata/app/robots/page.js | 17 +++ .../app-dir/metadata/app/twitter/app/page.js | 7 +- test/e2e/app-dir/metadata/metadata.test.ts | 53 ++++++- 13 files changed, 287 insertions(+), 100 deletions(-) create mode 100644 test/e2e/app-dir/metadata/app/app-links/page.js create mode 100644 test/e2e/app-dir/metadata/app/robots/page.js diff --git a/packages/next/src/lib/metadata/generate/basic.tsx b/packages/next/src/lib/metadata/generate/basic.tsx index ae9e642786148..b64e6a5e746a7 100644 --- a/packages/next/src/lib/metadata/generate/basic.tsx +++ b/packages/next/src/lib/metadata/generate/basic.tsx @@ -21,7 +21,8 @@ export function BasicMetadata({ metadata }: { metadata: ResolvedMetadata }) { - + + {metadata.archives ? metadata.archives.map((archive) => ( diff --git a/packages/next/src/lib/metadata/generate/meta.tsx b/packages/next/src/lib/metadata/generate/meta.tsx index 6253b4da33eae..90a6fb00f3f06 100644 --- a/packages/next/src/lib/metadata/generate/meta.tsx +++ b/packages/next/src/lib/metadata/generate/meta.tsx @@ -20,6 +20,43 @@ export function Meta({ return null } +type ExtendMetaContent = Record< + string, + undefined | string | URL | number | boolean | null | undefined +> +type MultiMetaContent = + | (ExtendMetaContent | string | URL | number)[] + | null + | undefined + +export function ExtendMeta({ + content, + namePrefix, + propertyPrefix, +}: { + content?: ExtendMetaContent + namePrefix?: string + propertyPrefix?: string +}) { + const keyPrefix = namePrefix || propertyPrefix + if (!content) return null + return ( + + {Object.entries(content).map(([k, v], index) => { + return typeof v === 'undefined' ? null : ( + + ) + })} + + ) +} + export function MultiMeta({ propertyPrefix, namePrefix, @@ -27,15 +64,7 @@ export function MultiMeta({ }: { propertyPrefix?: string namePrefix?: string - contents: - | ( - | Record - | string - | URL - | number - )[] - | null - | undefined + contents?: MultiMetaContent | null }) { if (typeof contents === 'undefined' || contents === null) { return null @@ -61,19 +90,11 @@ export function MultiMeta({ ) } else { return ( - - {Object.entries(content).map(([k, v]) => { - return typeof v === 'undefined' ? null : ( - - ) - })} - + ) } })} diff --git a/packages/next/src/lib/metadata/generate/opengraph.tsx b/packages/next/src/lib/metadata/generate/opengraph.tsx index 5f7d7792a0e31..2d2eae31af563 100644 --- a/packages/next/src/lib/metadata/generate/opengraph.tsx +++ b/packages/next/src/lib/metadata/generate/opengraph.tsx @@ -1,7 +1,7 @@ import type { ResolvedMetadata } from '../types/metadata-interface' import React from 'react' -import { Meta, MultiMeta } from './meta' +import { ExtendMeta, Meta, MultiMeta } from './meta' import { TwitterAppDescriptor } from '../types/twitter-types' export function OpenGraphMetadata({ @@ -232,9 +232,9 @@ function TwitterAppItem({ }) { return ( <> - - - + + + ) } @@ -283,3 +283,29 @@ export function TwitterMetadata({ ) } + +export function AppLinksMeta({ + appLinks, +}: { + appLinks: ResolvedMetadata['appLinks'] +}) { + if (!appLinks) return null + return ( + <> + + + + + + + + + + ) +} diff --git a/packages/next/src/lib/metadata/metadata.tsx b/packages/next/src/lib/metadata/metadata.tsx index 27950d3ae3e1f..a92e7a54e5521 100644 --- a/packages/next/src/lib/metadata/metadata.tsx +++ b/packages/next/src/lib/metadata/metadata.tsx @@ -8,7 +8,11 @@ import { BasicMetadata, } from './generate/basic' import { AlternatesMetadata } from './generate/alternate' -import { OpenGraphMetadata, TwitterMetadata } from './generate/opengraph' +import { + OpenGraphMetadata, + TwitterMetadata, + AppLinksMeta, +} from './generate/opengraph' import { resolveMetadata } from './resolve-metadata' import { IconsMetadata } from './generate/icons' @@ -26,6 +30,7 @@ export async function Metadata({ metadata }: { metadata: any }) { + ) diff --git a/packages/next/src/lib/metadata/resolve-metadata.ts b/packages/next/src/lib/metadata/resolve-metadata.ts index 5c084185aca67..ca4ba62653f96 100644 --- a/packages/next/src/lib/metadata/resolve-metadata.ts +++ b/packages/next/src/lib/metadata/resolve-metadata.ts @@ -16,6 +16,10 @@ import { resolveOpenGraph } from './resolve-opengraph' import { mergeTitle } from './resolve-title' import { resolveAsArrayOrUndefined } from './generate/utils' +type FieldResolver = ( + T: Metadata[Key] +) => ResolvedMetadata[Key] + const viewPortKeys = { width: 'width', height: 'height', @@ -54,9 +58,7 @@ type Item = path?: string } -function resolveViewport( - viewport: Metadata['viewport'] -): ResolvedMetadata['viewport'] { +const resolveViewport: FieldResolver<'viewport'> = (viewport) => { let resolved: ResolvedMetadata['viewport'] = null if (typeof viewport === 'string') { @@ -74,9 +76,7 @@ function resolveViewport( return resolved } -function resolveVerification( - verification: Metadata['verification'] -): ResolvedMetadata['verification'] { +const resolveVerification: FieldResolver<'verification'> = (verification) => { const google = resolveAsArrayOrUndefined(verification?.google) const yahoo = resolveAsArrayOrUndefined(verification?.yahoo) let other: Record | undefined @@ -113,7 +113,7 @@ const TwitterBasicInfoKeys = [ 'description', ] as const -function resolveIcons(icons: Metadata['icons']): ResolvedMetadata['icons'] { +const resolveIcons: FieldResolver<'icons'> = (icons) => { if (!icons) { return null } @@ -132,9 +132,7 @@ function resolveIcons(icons: Metadata['icons']): ResolvedMetadata['icons'] { return resolved } -function resolveAppleWebApp( - appWebApp: Metadata['appleWebApp'] -): ResolvedMetadata['appleWebApp'] { +const resolveAppleWebApp: FieldResolver<'appleWebApp'> = (appWebApp) => { if (!appWebApp) return null if (appWebApp === true) { return { @@ -154,6 +152,76 @@ function resolveAppleWebApp( } } +const resolveTwitter: FieldResolver<'twitter'> = (twitter) => { + if (!twitter) return null + const resolved = { + title: twitter.title, + } as ResolvedTwitterMetadata + for (const infoKey of TwitterBasicInfoKeys) { + resolved[infoKey] = twitter[infoKey] || null + } + resolved.images = resolveAsArrayOrUndefined(twitter.images) || [] + if ('card' in twitter) { + resolved.card = twitter.card + switch (twitter.card) { + case 'player': { + // @ts-ignore + resolved.players = resolveAsArrayOrUndefined(twitter.players) || [] + break + } + case 'app': { + // @ts-ignore + resolved.app = twitter.app || {} + break + } + default: + break + } + } else { + resolved.card = 'summary' + } + return resolved +} + +const resolveAppLinks: FieldResolver<'appLinks'> = (appLinks) => { + if (!appLinks) return null + for (const key in appLinks) { + // @ts-ignore // TODO: type infer + appLinks[key] = resolveAsArrayOrUndefined(appLinks[key]) + } + return appLinks as ResolvedMetadata['appLinks'] +} + +const resolveRobotsValue: (robots: Metadata['robots']) => string | null = ( + robots +) => { + if (!robots) return null + if (typeof robots === 'string') return robots + + const values = [] + + if (robots.index) values.push('index') + else if (typeof robots.index === 'boolean') values.push('noindex') + + if (robots.follow) values.push('follow') + else if (typeof robots.follow === 'boolean') values.push('nofollow') + if (robots.noarchive) values.push('noarchive') + if (robots.nosnippet) values.push('nosnippet') + if (robots.noimageindex) values.push('noimageindex') + if (robots.nocache) values.push('nocache') + + return values.join(', ') +} + +const resolveRobots: FieldResolver<'robots'> = (robots) => { + if (!robots) return null + return { + basic: resolveRobotsValue(robots), + googleBot: + typeof robots !== 'string' ? resolveRobotsValue(robots.googleBot) : null, + } +} + // Merge the source metadata into the resolved target metadata. function merge( target: ResolvedMetadata, @@ -187,41 +255,9 @@ function merge( break } case 'twitter': { - if (source.twitter) { - // TODO: improve typing of merging - const resolved = { - title: source.twitter.title, - } as ResolvedTwitterMetadata - for (const infoKey of TwitterBasicInfoKeys) { - resolved[infoKey] = source.twitter[infoKey] || null - } - mergeTitle(resolved, templateStrings.twitter) - resolved.images = - resolveAsArrayOrUndefined(source.twitter.images) || [] - if ('card' in source.twitter) { - resolved.card = source.twitter.card - switch (source.twitter.card) { - case 'player': { - // @ts-ignore - resolved.players = - resolveAsArrayOrUndefined(source.twitter.players) || [] - break - } - case 'app': { - // @ts-ignore - resolved.app = source.twitter.app || {} - break - } - default: - break - } - } else { - resolved.card = 'summary' - } - - target.twitter = resolved - } else { - target.twitter = null + target.twitter = resolveTwitter(source.twitter) + if (target.twitter) { + mergeTitle(target.twitter, templateStrings.twitter) } break } @@ -236,12 +272,15 @@ function merge( target.icons = resolveIcons(source.icons) break } - case 'appLinks': - case 'verification': - break case 'appleWebApp': target.appleWebApp = resolveAppleWebApp(source.appleWebApp) break + case 'appLinks': + target.appLinks = resolveAppLinks(source.appLinks) + case 'robots': { + target.robots = resolveRobots(source.robots) + break + } case 'archives': case 'assets': case 'bookmarks': @@ -270,14 +309,6 @@ function merge( // @ts-ignore TODO: support inferring target[key] = source[key] || null break - // TODO: support more fields - case 'robots': { - // TODO: resolve robots - if (typeof source.robots === 'string') { - target.robots = source.robots - } - break - } default: break } diff --git a/packages/next/src/lib/metadata/types/extra-types.ts b/packages/next/src/lib/metadata/types/extra-types.ts index 8c804e709b766..de037f90d12a4 100644 --- a/packages/next/src/lib/metadata/types/extra-types.ts +++ b/packages/next/src/lib/metadata/types/extra-types.ts @@ -12,6 +12,16 @@ export type AppLinks = { windows_universal?: AppLinksWindows | Array web?: AppLinksWeb | Array } +export type ResolvedAppLinks = { + ios?: Array + iphone?: Array + ipad?: Array + android?: Array + windows_phone?: Array + windows?: Array + windows_universal?: Array + web?: Array +} export type AppLinksApple = { url: string | URL app_store_id?: string | number diff --git a/packages/next/src/lib/metadata/types/metadata-interface.ts b/packages/next/src/lib/metadata/types/metadata-interface.ts index 251b9450c2cb2..5d36236283660 100644 --- a/packages/next/src/lib/metadata/types/metadata-interface.ts +++ b/packages/next/src/lib/metadata/types/metadata-interface.ts @@ -8,6 +8,7 @@ import type { FormatDetection, ItunesApp, ResolvedAppleWebApp, + ResolvedAppLinks, Viewport, } from './extra-types' import type { @@ -21,6 +22,7 @@ import type { ResolvedIcons, ResolvedVerification, Robots, + ResolvedRobots, TemplateString, Verification, } from './metadata-types' @@ -142,7 +144,7 @@ export interface ResolvedMetadata { publisher: null | string // https://developer.mozilla.org/en-US/docs/Web/HTML/Element/meta/name#other_metadata_names - robots: null | string + robots: null | ResolvedRobots // The canonical and alternate URLs for this location alternates: null | ResolvedAlternateURLs @@ -177,7 +179,7 @@ export interface ResolvedMetadata { abstract: null | string // Facebook AppLinks - appLinks: null | AppLinks + appLinks: null | ResolvedAppLinks // link rel properties archives: null | Array diff --git a/packages/next/src/lib/metadata/types/metadata-types.ts b/packages/next/src/lib/metadata/types/metadata-types.ts index d6f8067004d2a..b098018f0a1d7 100644 --- a/packages/next/src/lib/metadata/types/metadata-types.ts +++ b/packages/next/src/lib/metadata/types/metadata-types.ts @@ -45,7 +45,7 @@ export type ColorSchemeEnum = | 'dark light' | 'only light' -export type Robots = { +type RobotsInfo = { // all and none will be inferred from index/follow boolean options index?: boolean follow?: boolean @@ -59,9 +59,15 @@ export type Robots = { nosnippet?: boolean noimageindex?: boolean nocache?: boolean - +} +export type Robots = RobotsInfo & { // if you want to specify an alternate robots just for google - googleBot?: string | Robots + googleBot?: string | RobotsInfo +} + +export type ResolvedRobots = { + basic: string | null + googleBot: string | null } export type IconURL = string | URL diff --git a/packages/next/src/server/app-render.tsx b/packages/next/src/server/app-render.tsx index 80e0b3ce758f2..51fa642917399 100644 --- a/packages/next/src/server/app-render.tsx +++ b/packages/next/src/server/app-render.tsx @@ -6,6 +6,7 @@ import type { FontLoaderManifest } from '../build/webpack/plugins/font-loader-ma // Import builtin react directly to avoid require cache conflicts import React, { use } from 'next/dist/compiled/react' import { NotFound as DefaultNotFound } from '../client/components/error' +import { nanoid } from 'next/dist/compiled/nanoid/index.cjs' // this needs to be required lazily so that `next-server` can set // the env before we require @@ -45,9 +46,9 @@ import { RSC, } from '../client/components/app-router-headers' import type { StaticGenerationAsyncStorage } from '../client/components/static-generation-async-storage' +import type { RequestAsyncStorage } from '../client/components/request-async-storage' import { formatServerError } from '../lib/format-server-error' import { Metadata } from '../lib/metadata/metadata' -import type { RequestAsyncStorage } from '../client/components/request-async-storage' import { runWithRequestAsyncStorage } from './run-with-request-async-storage' import { runWithStaticGenerationAsyncStorage } from './run-with-static-generation-async-storage' @@ -1710,8 +1711,9 @@ export async function renderToHTMLOrFlight( isFirst: true, rscPayloadHead: ( <> + {/* Adding key={requestId} to make metadata remount for each render */} {/* @ts-expect-error allow to use async server component */} - + {rscPayloadHead} ), @@ -1758,6 +1760,8 @@ export async function renderToHTMLOrFlight( // TODO-APP: validate req.url as it gets passed to render. const initialCanonicalUrl = req.url! + const requestId = nanoid(12) + // Get the nonce from the incoming request if it has one. const csp = req.headers['content-security-policy'] let nonce: string | undefined @@ -1809,8 +1813,9 @@ export async function renderToHTMLOrFlight( initialTree={initialTree} initialHead={ <> + {/* Adding key={requestId} to make metadata remount for each render */} {/* @ts-expect-error allow to use async server component */} - + {initialHead} } diff --git a/test/e2e/app-dir/metadata/app/app-links/page.js b/test/e2e/app-dir/metadata/app/app-links/page.js new file mode 100644 index 0000000000000..dd0cbc05635a2 --- /dev/null +++ b/test/e2e/app-dir/metadata/app/app-links/page.js @@ -0,0 +1,19 @@ +export default function Page() { + return

app links

+} + +export const metadata = { + appLinks: { + ios: { + url: 'https://example.com/ios', + app_store_id: 'app_store_id', + }, + android: { + package: 'com.example.android/package', + app_name: 'app_name_android', + }, + web: { + should_fallback: true, + }, + }, +} diff --git a/test/e2e/app-dir/metadata/app/robots/page.js b/test/e2e/app-dir/metadata/app/robots/page.js new file mode 100644 index 0000000000000..133032ee32cd5 --- /dev/null +++ b/test/e2e/app-dir/metadata/app/robots/page.js @@ -0,0 +1,17 @@ +export default function page() { + return

robots

+} + +export const metadata = { + robots: { + index: false, + follow: true, + nocache: true, + + googleBot: { + index: true, + follow: false, + noimageindex: true, + }, + }, +} diff --git a/test/e2e/app-dir/metadata/app/twitter/app/page.js b/test/e2e/app-dir/metadata/app/twitter/app/page.js index 4b2ffa6a890f0..ac40221be3a65 100644 --- a/test/e2e/app-dir/metadata/app/twitter/app/page.js +++ b/test/e2e/app-dir/metadata/app/twitter/app/page.js @@ -18,11 +18,12 @@ export const metadata = { name: 'twitter_app', id: { iphone: 'twitter_app://iphone', - ipad: 'twitter_app://iphone', + ipad: 'twitter_app://ipad', + googleplay: 'twitter_app://googleplay', }, url: { - iphone: 'twitter_app://iphone', - ipad: 'twitter_app://iphone', + iphone: 'https://iphone_url', + ipad: 'https://ipad_url', }, }, }, diff --git a/test/e2e/app-dir/metadata/metadata.test.ts b/test/e2e/app-dir/metadata/metadata.test.ts index e3b2bda263f37..c88506a29dc8d 100644 --- a/test/e2e/app-dir/metadata/metadata.test.ts +++ b/test/e2e/app-dir/metadata/metadata.test.ts @@ -225,6 +225,49 @@ createNextDescribe( ) }) + it('should support robots tags', async () => { + const browser = await next.browser('/robots') + await checkMetaNameContentPair( + browser, + 'robots', + 'noindex, follow, nocache' + ) + await checkMetaNameContentPair( + browser, + 'googlebot', + 'index, nofollow, noimageindex' + ) + }) + + it('should support appLinks tags', async () => { + const browser = await next.browser('/app-links') + await checkMetaPropertyContentPair( + browser, + 'al:ios:url', + 'https://example.com/ios' + ) + await checkMetaPropertyContentPair( + browser, + 'al:ios:app_store_id', + 'app_store_id' + ) + await checkMetaPropertyContentPair( + browser, + 'al:android:package', + 'com.example.android/package' + ) + await checkMetaPropertyContentPair( + browser, + 'al:android:app_name', + 'app_name_android' + ) + await checkMetaPropertyContentPair( + browser, + 'al:web:should_fallback', + 'true' + ) + }) + it('should apply metadata when navigating client-side', async () => { const browser = await next.browser('/') @@ -456,11 +499,11 @@ createNextDescribe( // app properties card: 'app', 'app:id:iphone': 'twitter_app://iphone', - 'app:url:iphone': 'twitter_app://iphone', - 'app:id:ipad': 'twitter_app://iphone', - 'app:url:ipad': 'twitter_app://iphone', - 'app:id:googleplay': 'twitter_app://iphone', - 'app:url:googleplay': 'twitter_app://iphone', + 'app:id:ipad': 'twitter_app://ipad', + 'app:id:googleplay': 'twitter_app://googleplay', + 'app:url:iphone': 'https://iphone_url', + 'app:url:ipad': 'https://ipad_url', + 'app:url:googleplay': undefined, } await Promise.all( From 3a8292f558276950a71ca6b06f2233a5428c3510 Mon Sep 17 00:00:00 2001 From: Jiachi Liu Date: Thu, 26 Jan 2023 17:12:14 +0100 Subject: [PATCH 08/10] move tests to ts --- test/e2e/app-dir/metadata/app/alternate/{page.js => page.tsx} | 2 +- test/e2e/app-dir/metadata/app/app-links/{page.js => page.tsx} | 2 +- test/e2e/app-dir/metadata/app/apple/{page.js => page.tsx} | 0 test/e2e/app-dir/metadata/app/basic/{page.js => page.tsx} | 0 .../app-dir/metadata/app/icons/descriptor/{page.js => page.tsx} | 0 test/e2e/app-dir/metadata/app/icons/{page.js => page.tsx} | 0 .../e2e/app-dir/metadata/app/icons/string/{page.js => page.tsx} | 0 test/e2e/app-dir/metadata/app/{layout.js => layout.tsx} | 0 .../metadata/app/opengraph/article/{page.js => page.tsx} | 2 +- test/e2e/app-dir/metadata/app/opengraph/{page.js => page.tsx} | 2 +- test/e2e/app-dir/metadata/app/{page.js => page.tsx} | 1 + test/e2e/app-dir/metadata/app/robots/{page.js => page.tsx} | 2 +- .../app/title-template/extra/inner/{page.js => page.tsx} | 0 .../metadata/app/title-template/extra/{layout.js => layout.tsx} | 0 .../metadata/app/title-template/extra/{page.js => page.tsx} | 2 +- .../metadata/app/title-template/{layout.js => layout.tsx} | 0 .../app-dir/metadata/app/title-template/{page.js => page.tsx} | 2 +- test/e2e/app-dir/metadata/app/title/{page.js => page.tsx} | 1 + test/e2e/app-dir/metadata/app/twitter/app/{page.js => page.tsx} | 0 .../metadata/app/twitter/large-image/{page.js => page.tsx} | 0 test/e2e/app-dir/metadata/app/twitter/{page.js => page.tsx} | 0 .../app-dir/metadata/app/twitter/player/{page.js => page.tsx} | 0 .../app-dir/metadata/app/viewport/object/{page.js => page.tsx} | 2 +- 23 files changed, 10 insertions(+), 8 deletions(-) rename test/e2e/app-dir/metadata/app/alternate/{page.js => page.tsx} (94%) rename test/e2e/app-dir/metadata/app/app-links/{page.js => page.tsx} (92%) rename test/e2e/app-dir/metadata/app/apple/{page.js => page.tsx} (100%) rename test/e2e/app-dir/metadata/app/basic/{page.js => page.tsx} (100%) rename test/e2e/app-dir/metadata/app/icons/descriptor/{page.js => page.tsx} (100%) rename test/e2e/app-dir/metadata/app/icons/{page.js => page.tsx} (100%) rename test/e2e/app-dir/metadata/app/icons/string/{page.js => page.tsx} (100%) rename test/e2e/app-dir/metadata/app/{layout.js => layout.tsx} (100%) rename test/e2e/app-dir/metadata/app/opengraph/article/{page.js => page.tsx} (90%) rename test/e2e/app-dir/metadata/app/opengraph/{page.js => page.tsx} (96%) rename test/e2e/app-dir/metadata/app/{page.js => page.tsx} (93%) rename test/e2e/app-dir/metadata/app/robots/{page.js => page.tsx} (90%) rename test/e2e/app-dir/metadata/app/title-template/extra/inner/{page.js => page.tsx} (100%) rename test/e2e/app-dir/metadata/app/title-template/extra/{layout.js => layout.tsx} (100%) rename test/e2e/app-dir/metadata/app/title-template/extra/{page.js => page.tsx} (79%) rename test/e2e/app-dir/metadata/app/title-template/{layout.js => layout.tsx} (100%) rename test/e2e/app-dir/metadata/app/title-template/{page.js => page.tsx} (75%) rename test/e2e/app-dir/metadata/app/title/{page.js => page.tsx} (90%) rename test/e2e/app-dir/metadata/app/twitter/app/{page.js => page.tsx} (100%) rename test/e2e/app-dir/metadata/app/twitter/large-image/{page.js => page.tsx} (100%) rename test/e2e/app-dir/metadata/app/twitter/{page.js => page.tsx} (100%) rename test/e2e/app-dir/metadata/app/twitter/player/{page.js => page.tsx} (100%) rename test/e2e/app-dir/metadata/app/viewport/object/{page.js => page.tsx} (85%) diff --git a/test/e2e/app-dir/metadata/app/alternate/page.js b/test/e2e/app-dir/metadata/app/alternate/page.tsx similarity index 94% rename from test/e2e/app-dir/metadata/app/alternate/page.js rename to test/e2e/app-dir/metadata/app/alternate/page.tsx index 25777130e9a6f..ac0bd68f88509 100644 --- a/test/e2e/app-dir/metadata/app/alternate/page.js +++ b/test/e2e/app-dir/metadata/app/alternate/page.tsx @@ -1,5 +1,5 @@ export default function Page() { - return

hello

+ return 'hello' } export const metadata = { diff --git a/test/e2e/app-dir/metadata/app/app-links/page.js b/test/e2e/app-dir/metadata/app/app-links/page.tsx similarity index 92% rename from test/e2e/app-dir/metadata/app/app-links/page.js rename to test/e2e/app-dir/metadata/app/app-links/page.tsx index dd0cbc05635a2..047bbd89579fe 100644 --- a/test/e2e/app-dir/metadata/app/app-links/page.js +++ b/test/e2e/app-dir/metadata/app/app-links/page.tsx @@ -1,5 +1,5 @@ export default function Page() { - return

app links

+ return 'app links' } export const metadata = { diff --git a/test/e2e/app-dir/metadata/app/apple/page.js b/test/e2e/app-dir/metadata/app/apple/page.tsx similarity index 100% rename from test/e2e/app-dir/metadata/app/apple/page.js rename to test/e2e/app-dir/metadata/app/apple/page.tsx diff --git a/test/e2e/app-dir/metadata/app/basic/page.js b/test/e2e/app-dir/metadata/app/basic/page.tsx similarity index 100% rename from test/e2e/app-dir/metadata/app/basic/page.js rename to test/e2e/app-dir/metadata/app/basic/page.tsx diff --git a/test/e2e/app-dir/metadata/app/icons/descriptor/page.js b/test/e2e/app-dir/metadata/app/icons/descriptor/page.tsx similarity index 100% rename from test/e2e/app-dir/metadata/app/icons/descriptor/page.js rename to test/e2e/app-dir/metadata/app/icons/descriptor/page.tsx diff --git a/test/e2e/app-dir/metadata/app/icons/page.js b/test/e2e/app-dir/metadata/app/icons/page.tsx similarity index 100% rename from test/e2e/app-dir/metadata/app/icons/page.js rename to test/e2e/app-dir/metadata/app/icons/page.tsx diff --git a/test/e2e/app-dir/metadata/app/icons/string/page.js b/test/e2e/app-dir/metadata/app/icons/string/page.tsx similarity index 100% rename from test/e2e/app-dir/metadata/app/icons/string/page.js rename to test/e2e/app-dir/metadata/app/icons/string/page.tsx diff --git a/test/e2e/app-dir/metadata/app/layout.js b/test/e2e/app-dir/metadata/app/layout.tsx similarity index 100% rename from test/e2e/app-dir/metadata/app/layout.js rename to test/e2e/app-dir/metadata/app/layout.tsx diff --git a/test/e2e/app-dir/metadata/app/opengraph/article/page.js b/test/e2e/app-dir/metadata/app/opengraph/article/page.tsx similarity index 90% rename from test/e2e/app-dir/metadata/app/opengraph/article/page.js rename to test/e2e/app-dir/metadata/app/opengraph/article/page.tsx index 4b92b111d6610..00c63aadca760 100644 --- a/test/e2e/app-dir/metadata/app/opengraph/article/page.js +++ b/test/e2e/app-dir/metadata/app/opengraph/article/page.tsx @@ -1,5 +1,5 @@ export default function Page() { - return

hello

+ return 'opengraph-article' } export const metadata = { diff --git a/test/e2e/app-dir/metadata/app/opengraph/page.js b/test/e2e/app-dir/metadata/app/opengraph/page.tsx similarity index 96% rename from test/e2e/app-dir/metadata/app/opengraph/page.js rename to test/e2e/app-dir/metadata/app/opengraph/page.tsx index 970a521bf382a..87f5e077ba50e 100644 --- a/test/e2e/app-dir/metadata/app/opengraph/page.js +++ b/test/e2e/app-dir/metadata/app/opengraph/page.tsx @@ -1,5 +1,5 @@ export default function Page() { - return

hello

+ return 'opengraph' } export const metadata = { diff --git a/test/e2e/app-dir/metadata/app/page.js b/test/e2e/app-dir/metadata/app/page.tsx similarity index 93% rename from test/e2e/app-dir/metadata/app/page.js rename to test/e2e/app-dir/metadata/app/page.tsx index 202d1179a5c86..386d93f3d8572 100644 --- a/test/e2e/app-dir/metadata/app/page.js +++ b/test/e2e/app-dir/metadata/app/page.tsx @@ -1,3 +1,4 @@ +import React from 'react' import Link from 'next/link' export default function Page() { diff --git a/test/e2e/app-dir/metadata/app/robots/page.js b/test/e2e/app-dir/metadata/app/robots/page.tsx similarity index 90% rename from test/e2e/app-dir/metadata/app/robots/page.js rename to test/e2e/app-dir/metadata/app/robots/page.tsx index 133032ee32cd5..14170a7322880 100644 --- a/test/e2e/app-dir/metadata/app/robots/page.js +++ b/test/e2e/app-dir/metadata/app/robots/page.tsx @@ -1,5 +1,5 @@ export default function page() { - return

robots

+ return 'robots' } export const metadata = { diff --git a/test/e2e/app-dir/metadata/app/title-template/extra/inner/page.js b/test/e2e/app-dir/metadata/app/title-template/extra/inner/page.tsx similarity index 100% rename from test/e2e/app-dir/metadata/app/title-template/extra/inner/page.js rename to test/e2e/app-dir/metadata/app/title-template/extra/inner/page.tsx diff --git a/test/e2e/app-dir/metadata/app/title-template/extra/layout.js b/test/e2e/app-dir/metadata/app/title-template/extra/layout.tsx similarity index 100% rename from test/e2e/app-dir/metadata/app/title-template/extra/layout.js rename to test/e2e/app-dir/metadata/app/title-template/extra/layout.tsx diff --git a/test/e2e/app-dir/metadata/app/title-template/extra/page.js b/test/e2e/app-dir/metadata/app/title-template/extra/page.tsx similarity index 79% rename from test/e2e/app-dir/metadata/app/title-template/extra/page.js rename to test/e2e/app-dir/metadata/app/title-template/extra/page.tsx index ea77ded22896d..cf31b0eddb268 100644 --- a/test/e2e/app-dir/metadata/app/title-template/extra/page.js +++ b/test/e2e/app-dir/metadata/app/title-template/extra/page.tsx @@ -1,5 +1,5 @@ export default function Page() { - return

hello

+ return 'extra' } export const metadata = { diff --git a/test/e2e/app-dir/metadata/app/title-template/layout.js b/test/e2e/app-dir/metadata/app/title-template/layout.tsx similarity index 100% rename from test/e2e/app-dir/metadata/app/title-template/layout.js rename to test/e2e/app-dir/metadata/app/title-template/layout.tsx diff --git a/test/e2e/app-dir/metadata/app/title-template/page.js b/test/e2e/app-dir/metadata/app/title-template/page.tsx similarity index 75% rename from test/e2e/app-dir/metadata/app/title-template/page.js rename to test/e2e/app-dir/metadata/app/title-template/page.tsx index 2fbc7681df1ea..1d63a094cf7c4 100644 --- a/test/e2e/app-dir/metadata/app/title-template/page.js +++ b/test/e2e/app-dir/metadata/app/title-template/page.tsx @@ -1,5 +1,5 @@ export default function Page() { - return

hello

+ return 'title template' } export const metadata = { diff --git a/test/e2e/app-dir/metadata/app/title/page.js b/test/e2e/app-dir/metadata/app/title/page.tsx similarity index 90% rename from test/e2e/app-dir/metadata/app/title/page.js rename to test/e2e/app-dir/metadata/app/title/page.tsx index 9e63975b66874..e2e9c35731332 100644 --- a/test/e2e/app-dir/metadata/app/title/page.js +++ b/test/e2e/app-dir/metadata/app/title/page.tsx @@ -1,3 +1,4 @@ +import React from 'react' import Link from 'next/link' export default function Page() { diff --git a/test/e2e/app-dir/metadata/app/twitter/app/page.js b/test/e2e/app-dir/metadata/app/twitter/app/page.tsx similarity index 100% rename from test/e2e/app-dir/metadata/app/twitter/app/page.js rename to test/e2e/app-dir/metadata/app/twitter/app/page.tsx diff --git a/test/e2e/app-dir/metadata/app/twitter/large-image/page.js b/test/e2e/app-dir/metadata/app/twitter/large-image/page.tsx similarity index 100% rename from test/e2e/app-dir/metadata/app/twitter/large-image/page.js rename to test/e2e/app-dir/metadata/app/twitter/large-image/page.tsx diff --git a/test/e2e/app-dir/metadata/app/twitter/page.js b/test/e2e/app-dir/metadata/app/twitter/page.tsx similarity index 100% rename from test/e2e/app-dir/metadata/app/twitter/page.js rename to test/e2e/app-dir/metadata/app/twitter/page.tsx diff --git a/test/e2e/app-dir/metadata/app/twitter/player/page.js b/test/e2e/app-dir/metadata/app/twitter/player/page.tsx similarity index 100% rename from test/e2e/app-dir/metadata/app/twitter/player/page.js rename to test/e2e/app-dir/metadata/app/twitter/player/page.tsx diff --git a/test/e2e/app-dir/metadata/app/viewport/object/page.js b/test/e2e/app-dir/metadata/app/viewport/object/page.tsx similarity index 85% rename from test/e2e/app-dir/metadata/app/viewport/object/page.js rename to test/e2e/app-dir/metadata/app/viewport/object/page.tsx index e82899ae607df..dc7ec50708dbe 100644 --- a/test/e2e/app-dir/metadata/app/viewport/object/page.js +++ b/test/e2e/app-dir/metadata/app/viewport/object/page.tsx @@ -1,5 +1,5 @@ export default function Page() { - return

viewport

+ return 'viewport' } export const metadata = { From 15e4db2eb50f7798395261e486b9e1a21c5e6deb Mon Sep 17 00:00:00 2001 From: Jiachi Liu Date: Thu, 26 Jan 2023 17:33:00 +0100 Subject: [PATCH 09/10] fix twitter image --- .../src/lib/metadata/generate/opengraph.tsx | 9 ++++++++- .../next/src/lib/metadata/resolve-metadata.ts | 19 +++++++++++++++---- .../src/lib/metadata/types/twitter-types.ts | 8 ++++++-- .../metadata/app/twitter/large-image/page.tsx | 5 ++++- test/e2e/app-dir/metadata/metadata.test.ts | 1 + 5 files changed, 34 insertions(+), 8 deletions(-) diff --git a/packages/next/src/lib/metadata/generate/opengraph.tsx b/packages/next/src/lib/metadata/generate/opengraph.tsx index 2d2eae31af563..0d8af60ebfe9a 100644 --- a/packages/next/src/lib/metadata/generate/opengraph.tsx +++ b/packages/next/src/lib/metadata/generate/opengraph.tsx @@ -256,7 +256,14 @@ export function TwitterMetadata({ - + {twitter.images + ? twitter.images.map((image) => ( + <> + + + + )) + : null} {card === 'player' ? twitter.players.map((player) => ( <> diff --git a/packages/next/src/lib/metadata/resolve-metadata.ts b/packages/next/src/lib/metadata/resolve-metadata.ts index ca4ba62653f96..1e9c9c6ba08b3 100644 --- a/packages/next/src/lib/metadata/resolve-metadata.ts +++ b/packages/next/src/lib/metadata/resolve-metadata.ts @@ -94,12 +94,12 @@ const resolveVerification: FieldResolver<'verification'> = (verification) => { } } -function isUrlIcon(icon: any): icon is string | URL { +function isStringOrURL(icon: any): icon is string | URL { return typeof icon === 'string' || icon instanceof URL } function resolveIcon(icon: Icon): IconDescriptor { - if (isUrlIcon(icon)) return { url: icon } + if (isStringOrURL(icon)) return { url: icon } else if (Array.isArray(icon)) return icon return icon } @@ -121,7 +121,7 @@ const resolveIcons: FieldResolver<'icons'> = (icons) => { const resolved: ResolvedMetadata['icons'] = {} if (Array.isArray(icons)) { resolved.icon = icons.map(resolveIcon).filter(Boolean) - } else if (isUrlIcon(icons)) { + } else if (isStringOrURL(icons)) { resolved.icon = [resolveIcon(icons)] } else { for (const key of IconKeys) { @@ -160,7 +160,18 @@ const resolveTwitter: FieldResolver<'twitter'> = (twitter) => { for (const infoKey of TwitterBasicInfoKeys) { resolved[infoKey] = twitter[infoKey] || null } - resolved.images = resolveAsArrayOrUndefined(twitter.images) || [] + resolved.images = resolveAsArrayOrUndefined(twitter.images)?.map((item) => { + if (isStringOrURL(item)) + return { + url: item.toString(), + } + else { + return { + url: item.url.toString(), + alt: item.alt, + } + } + }) if ('card' in twitter) { resolved.card = twitter.card switch (twitter.card) { diff --git a/packages/next/src/lib/metadata/types/twitter-types.ts b/packages/next/src/lib/metadata/types/twitter-types.ts index e1238c4c68293..de952d3736476 100644 --- a/packages/next/src/lib/metadata/types/twitter-types.ts +++ b/packages/next/src/lib/metadata/types/twitter-types.ts @@ -50,8 +50,8 @@ export type TwitterAppDescriptor = { type TwitterImage = string | TwitterImageDescriptor | URL type TwitterImageDescriptor = { url: string | URL - secureUrl?: string | URL alt?: string + secureUrl?: string | URL type?: string width?: string | number height?: string | number @@ -63,6 +63,10 @@ type TwitterPlayerDescriptor = { height: number } +type ResolvedTwitterImage = { + url: string + alt?: string +} type ResolvedTwitterSummary = { site: string | null siteId: string | null @@ -70,7 +74,7 @@ type ResolvedTwitterSummary = { creatorId: string | null description: string | null title: AbsoluteTemplateString - images: Array + images?: Array } type ResolvedTwitterPlayer = ResolvedTwitterSummary & { players: Array diff --git a/test/e2e/app-dir/metadata/app/twitter/large-image/page.tsx b/test/e2e/app-dir/metadata/app/twitter/large-image/page.tsx index 13ddd7b4a0181..3eafaf5ff8c25 100644 --- a/test/e2e/app-dir/metadata/app/twitter/large-image/page.tsx +++ b/test/e2e/app-dir/metadata/app/twitter/large-image/page.tsx @@ -10,6 +10,9 @@ export const metadata = { siteId: 'siteId', creator: 'creator', creatorId: 'creatorId', - images: 'https://twitter.com/image.png', + images: { + url: 'https://twitter.com/image.png', + alt: 'image-alt', + }, }, } diff --git a/test/e2e/app-dir/metadata/metadata.test.ts b/test/e2e/app-dir/metadata/metadata.test.ts index c88506a29dc8d..bf04e37d6ad7b 100644 --- a/test/e2e/app-dir/metadata/metadata.test.ts +++ b/test/e2e/app-dir/metadata/metadata.test.ts @@ -442,6 +442,7 @@ createNextDescribe( creator: 'creator', 'creator:id': 'creatorId', image: 'https://twitter.com/image.png', + 'image:alt': 'image-alt', card: 'summary_large_image', } From 063aa750b79276862544540a50f5d668e4bfa738 Mon Sep 17 00:00:00 2001 From: Jiachi Liu Date: Thu, 26 Jan 2023 17:51:12 +0100 Subject: [PATCH 10/10] fix lint --- packages/next/src/lib/metadata/resolve-metadata.ts | 1 + packages/next/src/server/app-render.tsx | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/next/src/lib/metadata/resolve-metadata.ts b/packages/next/src/lib/metadata/resolve-metadata.ts index 1e9c9c6ba08b3..3163435efc1b3 100644 --- a/packages/next/src/lib/metadata/resolve-metadata.ts +++ b/packages/next/src/lib/metadata/resolve-metadata.ts @@ -288,6 +288,7 @@ function merge( break case 'appLinks': target.appLinks = resolveAppLinks(source.appLinks) + break case 'robots': { target.robots = resolveRobots(source.robots) break diff --git a/packages/next/src/server/app-render.tsx b/packages/next/src/server/app-render.tsx index 51fa642917399..80c42143620a2 100644 --- a/packages/next/src/server/app-render.tsx +++ b/packages/next/src/server/app-render.tsx @@ -980,6 +980,8 @@ export async function renderToHTMLOrFlight( * The metadata items array created in next-app-loader with all relevant information * that we need to resolve the final metadata. */ + + const requestId = nanoid(12) const metadataItems = ComponentMod.metadata stripInternalQueries(query) @@ -1760,8 +1762,6 @@ export async function renderToHTMLOrFlight( // TODO-APP: validate req.url as it gets passed to render. const initialCanonicalUrl = req.url! - const requestId = nanoid(12) - // Get the nonce from the incoming request if it has one. const csp = req.headers['content-security-policy'] let nonce: string | undefined