diff --git a/packages/next/src/build/webpack/loaders/next-app-loader.ts b/packages/next/src/build/webpack/loaders/next-app-loader.ts index b6ee93ff9ec23..82a357af7bade 100644 --- a/packages/next/src/build/webpack/loaders/next-app-loader.ts +++ b/packages/next/src/build/webpack/loaders/next-app-loader.ts @@ -7,6 +7,7 @@ import { sep } from 'path' import { verifyRootLayout } from '../../../lib/verifyRootLayout' import * as Log from '../../../build/output/log' import { APP_DIR_ALIAS } from '../../../lib/constants' +import { resolveFileBasedMetadataForLoader } from '../../../lib/metadata/resolve-metadata' const FILE_TYPES = { layout: 'layout', @@ -36,7 +37,10 @@ async function createTreeCodeFromPath({ resolveParallelSegments, }: { pagePath: string - resolve: (pathname: string) => Promise + resolve: ( + pathname: string, + resolveDir?: boolean + ) => Promise resolveParallelSegments: ( pathname: string ) => [key: string, segment: string][] @@ -44,6 +48,7 @@ async function createTreeCodeFromPath({ const splittedPath = pagePath.split(/[\\/]/) const appDirPrefix = splittedPath[0] const pages: string[] = [] + let rootLayout: string | undefined let globalError: string | undefined @@ -51,6 +56,7 @@ async function createTreeCodeFromPath({ segments: string[] ): Promise<{ treeCode: string + treeMetadataCode: string }> { const segmentPath = segments.join('/') @@ -65,12 +71,26 @@ async function createTreeCodeFromPath({ parallelSegments.push(...resolveParallelSegments(segmentPath)) } + let metadataCode = '' + for (const [parallelKey, parallelSegment] of parallelSegments) { if (parallelSegment === PAGE_SEGMENT) { const matchedPagePath = `${appDirPrefix}${segmentPath}/page` const resolvedPagePath = await resolve(matchedPagePath) if (resolvedPagePath) pages.push(resolvedPagePath) + metadataCode += `{ + type: 'page', + layer: ${ + // There's an extra virtual segment. + segments.length - 1 + }, + mod: () => import(/* webpackMode: "eager" */ ${JSON.stringify( + resolvedPagePath + )}), + path: ${JSON.stringify(resolvedPagePath)}, + },` + // Use '' for segment as it's the page. There can't be a segment called '' so this is the safest way to add it. props[parallelKey] = `['', {}, { page: [() => import(/* webpackMode: "eager" */ ${JSON.stringify( @@ -80,9 +100,8 @@ async function createTreeCodeFromPath({ } const parallelSegmentPath = segmentPath + '/' + parallelSegment - const { treeCode: subtreeCode } = await createSubtreePropsFromSegmentPath( - [...segments, parallelSegment] - ) + const { treeCode: subtreeCode, treeMetadataCode: subTreeMetadataCode } = + await createSubtreePropsFromSegmentPath([...segments, parallelSegment]) // `page` is not included here as it's added above. const filePaths = await Promise.all( @@ -101,6 +120,27 @@ async function createTreeCodeFromPath({ rootLayout = layoutPath } + // Collect metadata for the layout + if (layoutPath) { + metadataCode += `{ + type: 'layout', + layer: ${segments.length}, + mod: () => import(/* webpackMode: "eager" */ ${JSON.stringify( + layoutPath + )}), + path: ${JSON.stringify(layoutPath)}, + },` + } + metadataCode += await resolveFileBasedMetadataForLoader( + segments.length, + (await resolve(`${appDirPrefix}${parallelSegmentPath}/`, true))! + ) + metadataCode += subTreeMetadataCode + + if (!rootLayout) { + rootLayout = layoutPath + } + if (!globalError) { globalError = await resolve( `${appDirPrefix}${parallelSegmentPath}/${GLOBAL_ERROR_FILE_TYPE}` @@ -133,13 +173,16 @@ async function createTreeCodeFromPath({ .map(([key, value]) => `${key}: ${value}`) .join(',\n')} }`, + treeMetadataCode: metadataCode, } } - const { treeCode } = await createSubtreePropsFromSegmentPath([]) + const { treeCode, treeMetadataCode } = + await createSubtreePropsFromSegmentPath([]) return { treeCode: `const tree = ${treeCode}.children;`, - pages, + treeMetadataCode: `const metadata = [${treeMetadataCode}];`, + pages: `const pages = ${JSON.stringify(pages)};`, rootLayout, globalError, } @@ -197,7 +240,7 @@ const nextAppLoader: webpack.LoaderDefinitionFunction<{ const rest = path.slice(pathname.length + 1).split('/') let matchedSegment = rest[0] - // It is the actual page, mark it sepcially. + // It is the actual page, mark it specially. if (rest.length === 1 && matchedSegment === 'page') { matchedSegment = PAGE_SEGMENT } @@ -212,7 +255,11 @@ const nextAppLoader: webpack.LoaderDefinitionFunction<{ return Object.entries(matched) } - const resolver = async (pathname: string) => { + const resolver = async (pathname: string, resolveDir?: boolean) => { + if (resolveDir) { + return createAbsolutePath(appDir, pathname) + } + try { const resolved = await resolve(this.rootContext, pathname) this.addDependency(resolved) @@ -230,12 +277,17 @@ const nextAppLoader: webpack.LoaderDefinitionFunction<{ } } - const { treeCode, pages, rootLayout, globalError } = - await createTreeCodeFromPath({ - pagePath, - resolve: resolver, - resolveParallelSegments, - }) + const { + treeCode, + treeMetadataCode, + pages: pageListCode, + rootLayout, + globalError, + } = await createTreeCodeFromPath({ + pagePath, + resolve: resolver, + resolveParallelSegments, + }) if (!rootLayout) { const errorMessage = `${chalk.bold( @@ -263,7 +315,8 @@ const nextAppLoader: webpack.LoaderDefinitionFunction<{ const result = ` export ${treeCode} - export const pages = ${JSON.stringify(pages)} + export ${treeMetadataCode} + export ${pageListCode} export { default as AppRouter } from 'next/dist/client/components/app-router' export { default as LayoutRouter } from 'next/dist/client/components/layout-router' diff --git a/packages/next/src/build/webpack/plugins/flight-types-plugin.ts b/packages/next/src/build/webpack/plugins/flight-types-plugin.ts index 3f41c5818adf1..783b3843cb442 100644 --- a/packages/next/src/build/webpack/plugins/flight-types-plugin.ts +++ b/packages/next/src/build/webpack/plugins/flight-types-plugin.ts @@ -64,6 +64,7 @@ interface IEntry { ? "runtime?: 'nodejs' | 'experimental-edge' | 'edge'" : '' } + metadata?: any } // ============= diff --git a/packages/next/src/client/components/head.tsx b/packages/next/src/client/components/head.tsx deleted file mode 100644 index 0b123102d6c7b..0000000000000 --- a/packages/next/src/client/components/head.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import React from 'react' - -export function DefaultHead() { - return ( - <> - - - - ) -} diff --git a/packages/next/src/lib/metadata/default-metadata.ts b/packages/next/src/lib/metadata/default-metadata.ts new file mode 100644 index 0000000000000..c160233a2fd31 --- /dev/null +++ b/packages/next/src/lib/metadata/default-metadata.ts @@ -0,0 +1,41 @@ +import type { ResolvedMetadata } from './types/metadata-interface' + +export const createDefaultMetadata = (): ResolvedMetadata => { + return { + viewport: 'width=device-width, initial-scale=1', + + // Other values are all null + metadataBase: null, + title: null, + description: null, + applicationName: null, + authors: null, + generator: null, + keywords: null, + referrer: null, + themeColor: null, + colorScheme: null, + creator: null, + publisher: null, + robots: null, + alternates: { + canonical: null, + languages: {}, + }, + icons: null, + openGraph: null, + twitter: null, + verification: {}, + appleWebApp: null, + formatDetection: null, + itunes: null, + abstract: null, + appLinks: null, + archives: null, + assets: null, + bookmarks: null, + category: null, + classification: null, + other: {}, + } +} diff --git a/packages/next/src/lib/metadata/generate/alternate.tsx b/packages/next/src/lib/metadata/generate/alternate.tsx new file mode 100644 index 0000000000000..4bd4eddd148b8 --- /dev/null +++ b/packages/next/src/lib/metadata/generate/alternate.tsx @@ -0,0 +1,51 @@ +import type { ResolvedMetadata } from '../types/metadata-interface' + +import React from 'react' + +export function ResolvedAlternatesMetadata({ + metadata, +}: { + metadata: ResolvedMetadata +}) { + return ( + <> + {metadata.alternates.canonical ? ( + + ) : null} + {Object.entries(metadata.alternates.languages).map(([locale, url]) => + url ? ( + + ) : null + )} + {metadata.alternates.media + ? Object.entries(metadata.alternates.media).map(([media, url]) => + url ? ( + + ) : null + ) + : null} + {metadata.alternates.types + ? Object.entries(metadata.alternates.types).map(([type, url]) => + url ? ( + + ) : null + ) + : null} + + ) +} diff --git a/packages/next/src/lib/metadata/generate/basic.tsx b/packages/next/src/lib/metadata/generate/basic.tsx new file mode 100644 index 0000000000000..ce8b2f07eaa92 --- /dev/null +++ b/packages/next/src/lib/metadata/generate/basic.tsx @@ -0,0 +1,56 @@ +import type { ResolvedMetadata } from '../types/metadata-interface' + +import React from 'react' +import { Meta } from './utils' + +export function ResolvedBasicMetadata({ + metadata, +}: { + metadata: ResolvedMetadata +}) { + return ( + <> + + {metadata.title !== null ? ( + {metadata.title.absolute} + ) : null} + + + + + + + + + + + + + + {metadata.archives + ? metadata.archives.map((archive) => ( + + )) + : null} + {metadata.assets + ? metadata.assets.map((asset) => ( + + )) + : null} + {metadata.bookmarks + ? metadata.bookmarks.map((bookmark) => ( + + )) + : null} + + + {Object.entries(metadata.other).map(([name, content]) => ( + + ))} + + ) +} diff --git a/packages/next/src/lib/metadata/generate/opengraph.tsx b/packages/next/src/lib/metadata/generate/opengraph.tsx new file mode 100644 index 0000000000000..45389ce1dfc4b --- /dev/null +++ b/packages/next/src/lib/metadata/generate/opengraph.tsx @@ -0,0 +1,223 @@ +import type { ResolvedMetadata } from '../types/metadata-interface' + +import React from 'react' +import { Meta, MultiMeta } from './utils' + +export function ResolvedOpenGraphMetadata({ + openGraph, +}: { + openGraph: ResolvedMetadata['openGraph'] +}) { + if (!openGraph) { + return null + } + + let typedOpenGraph + if ('type' in openGraph) { + switch (openGraph.type) { + case 'website': + typedOpenGraph = + break + case 'article': + typedOpenGraph = ( + <> + + + + + + + + + ) + break + case 'book': + typedOpenGraph = ( + <> + + + + + + + ) + break + case 'profile': + typedOpenGraph = ( + <> + + + + + + + ) + break + case 'music.song': + typedOpenGraph = ( + <> + + + + + + ) + break + case 'music.album': + typedOpenGraph = ( + <> + + + + + + ) + break + case 'music.playlist': + typedOpenGraph = ( + <> + + + + + ) + break + case 'music.radio_station': + typedOpenGraph = ( + <> + + + + ) + break + case 'video.movie': + typedOpenGraph = ( + <> + + + + + + + + + ) + break + case 'video.episode': + typedOpenGraph = ( + <> + + + + + + + + + + ) + break + case 'video.tv_show': + typedOpenGraph = + break + case 'video.other': + typedOpenGraph = + break + default: + throw new Error('Invalid OpenGraph type: ' + (openGraph as any).type) + } + } + + return ( + <> + + + + + + + + + + + + + + + + {typedOpenGraph} + + ) +} diff --git a/packages/next/src/lib/metadata/generate/utils.tsx b/packages/next/src/lib/metadata/generate/utils.tsx new file mode 100644 index 0000000000000..6253b4da33eae --- /dev/null +++ b/packages/next/src/lib/metadata/generate/utils.tsx @@ -0,0 +1,82 @@ +import React from 'react' + +export function Meta({ + name, + property, + content, +}: { + name?: string + property?: string + content: string | number | URL | null | undefined +}): React.ReactElement | null { + if (typeof content !== 'undefined' && content !== null) { + return ( + + ) + } + return null +} + +export function MultiMeta({ + propertyPrefix, + namePrefix, + contents, +}: { + propertyPrefix?: string + namePrefix?: string + contents: + | ( + | Record + | string + | URL + | number + )[] + | null + | undefined +}) { + if (typeof contents === 'undefined' || contents === null) { + return null + } + + const keyPrefix = propertyPrefix || namePrefix + return ( + <> + {contents.map((content, index) => { + if ( + typeof content === 'string' || + typeof content === 'number' || + content instanceof URL + ) { + return ( + + ) + } else { + return ( + + {Object.entries(content).map(([k, v]) => { + return typeof v === 'undefined' ? null : ( + + ) + })} + + ) + } + })} + + ) +} diff --git a/packages/next/src/lib/metadata/resolve-metadata.ts b/packages/next/src/lib/metadata/resolve-metadata.ts new file mode 100644 index 0000000000000..c012969b6905d --- /dev/null +++ b/packages/next/src/lib/metadata/resolve-metadata.ts @@ -0,0 +1,224 @@ +import type { + Metadata, + ResolvedMetadata, + ResolvingMetadata, +} from './types/metadata-interface' +import type { Viewport } from './types/extra-types' +import type { ResolvedTwitterMetadata } from './types/twitter-types' +import type { AbsoluteTemplateString } from './types/metadata-types' +import { createDefaultMetadata } from './default-metadata' +import { resolveOpenGraph } from './resolve-opengraph' +import { mergeTitle } from './resolve-title' + +const viewPortKeys = { + width: 'width', + height: 'height', + initialScale: 'initial-scale', + minimumScale: 'minimum-scale', + maximumScale: 'maximum-scale', + viewportFit: 'viewport-fit', +} as const + +type Item = + | { + type: 'layout' | 'page' + // A number that represents which layer or routes that the item is in. Starting from 0. + // Layout and page in the same level will share the same `layer`. + layer: number + mod: () => Promise<{ + metadata?: Metadata + generateMetadata?: ( + props: any, + parent: ResolvingMetadata + ) => Promise + }> + path: string + } + | { + type: 'icon' + // A number that represents which layer the item is in. Starting from 0. + layer: number + mod?: () => Promise<{ + metadata?: Metadata + generateMetadata?: ( + props: any, + parent: ResolvingMetadata + ) => Promise + }> + path?: string + } + +// Merge the source metadata into the resolved target metadata. +function merge( + target: ResolvedMetadata, + source: Metadata, + templateStrings: { + title: string | null + openGraph: string | null + twitter: string | null + } +) { + for (const key_ in source) { + 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 + mergeTitle(target, templateStrings.title) + } + break + } + case 'openGraph': { + if (typeof source.openGraph !== 'undefined') { + target.openGraph = resolveOpenGraph(source.openGraph) + if (source.openGraph) { + mergeTitle(target.openGraph, templateStrings.openGraph) + } + } else { + target.openGraph = null + } + break + } + case 'twitter': { + if (source.twitter) { + target.twitter = source.twitter as ResolvedTwitterMetadata + mergeTitle(target.twitter, templateStrings.twitter) + } else { + target.twitter = null + } + break + } + case 'viewport': { + let content: string | null = null + const { viewport } = source + if (typeof viewport === 'string') { + content = viewport + } else if (viewport) { + content = '' + for (const viewportKey_ in viewPortKeys) { + const viewportKey = viewportKey_ as keyof Viewport + if (viewport[viewportKey]) { + if (content) content += ', ' + content += `${viewPortKeys[viewportKey]}=${viewport[viewportKey]}` + } + } + } + target.viewport = content + break + } + default: { + // TODO: Make sure the type is correct. + // @ts-ignore + target[key] = source[key] + break + } + } + } +} + +export async function resolveMetadata(metadataItems: Item[]) { + const resolvedMetadata = createDefaultMetadata() + + let committedTitleTemplate: string | null = null + let committedOpenGraphTitleTemplate: string | null = null + let committedTwitterTitleTemplate: string | null = null + + let lastLayer = 0 + // from root layout to page metadata + for (let i = 0; i < metadataItems.length; i++) { + const item = metadataItems[i] + const isLayout = item.type === 'layout' + const isPage = item.type === 'page' + if (isLayout || isPage) { + let layerMod = await item.mod() + + // Layer is a client component, we just skip it. It can't have metadata + // exported. Note that during our SWC transpilation, it should check if + // the exports are valid and give specific error messages. + if ( + '$$typeof' in layerMod && + (layerMod as any).$$typeof === Symbol.for('react.module.reference') + ) { + continue + } + + if (layerMod.metadata && layerMod.generateMetadata) { + throw new Error( + `A ${item.type} is exporting both metadata and generateMetadata which is not supported. If all of the metadata you want to associate to this ${item.type} is static use the metadata export, otherwise use generateMetadata. File: ` + + item.path + ) + } + + // If we resolved all items in this layer, commit the stashed titles. + if (item.layer >= lastLayer) { + committedTitleTemplate = resolvedMetadata.title?.template || null + committedOpenGraphTitleTemplate = + resolvedMetadata.openGraph?.title?.template || null + committedTwitterTitleTemplate = + resolvedMetadata.twitter?.title?.template || null + + lastLayer = item.layer + } + + if (layerMod.metadata) { + merge(resolvedMetadata, layerMod.metadata, { + title: committedTitleTemplate, + openGraph: committedOpenGraphTitleTemplate, + twitter: committedTwitterTitleTemplate, + }) + } else if (layerMod.generateMetadata) { + merge( + resolvedMetadata, + await layerMod.generateMetadata( + // TODO: Rewrite this to pass correct params and resolving metadata value. + {}, + Promise.resolve(resolvedMetadata) + ), + { + title: committedTitleTemplate, + openGraph: committedOpenGraphTitleTemplate, + twitter: committedTwitterTitleTemplate, + } + ) + } + } + } + + return resolvedMetadata +} + +// TODO: Implement this function. +export async function resolveFileBasedMetadataForLoader( + _layer: number, + _dir: string +) { + let metadataCode = '' + + // const files = await fs.readdir(path.normalize(dir)) + // for (const file of files) { + // // TODO: Get a full list and filter out directories. + // if (file === 'icon.svg') { + // metadataCode += `{ + // type: 'icon', + // layer: ${layer}, + // path: ${JSON.stringify(path.join(dir, file))}, + // },` + // } else if (file === 'icon.jsx') { + // metadataCode += `{ + // type: 'icon', + // layer: ${layer}, + // mod: () => import(/* webpackMode: "eager" */ ${JSON.stringify( + // path.join(dir, file) + // )}), + // path: ${JSON.stringify(path.join(dir, file))}, + // },` + // } + // } + + return metadataCode +} diff --git a/packages/next/src/lib/metadata/resolve-opengraph.ts b/packages/next/src/lib/metadata/resolve-opengraph.ts new file mode 100644 index 0000000000000..c322baaa32536 --- /dev/null +++ b/packages/next/src/lib/metadata/resolve-opengraph.ts @@ -0,0 +1,92 @@ +import type { Metadata } from './types/metadata-interface' +import type { + OpenGraphType, + OpenGraph, + ResolvedOpenGraph, +} from './types/opengraph-types' + +const OgTypFields = { + article: ['authors', 'tags'], + song: ['albums', 'musicians'], + playlist: ['albums', 'musicians'], + radio: ['creators'], + video: ['actors', 'directors', 'writers', 'tags'], + basic: [ + 'emails', + 'phoneNumbers', + 'faxNumbers', + 'alternateLocale', + 'images', + 'audio', + 'videos', + ], +} as const + +function resolveAsArrayOrUndefined(value: T): undefined | any[] { + if (typeof value === 'undefined' || value === null) { + return undefined + } + if (Array.isArray(value)) { + return value + } + return [value] +} + +function getFieldsByOgType(ogType: OpenGraphType | undefined) { + switch (ogType) { + case 'article': + case 'book': + return OgTypFields.article + case 'music.song': + case 'music.album': + return OgTypFields.song + case 'music.playlist': + return OgTypFields.playlist + case 'music.radio_station': + return OgTypFields.radio + case 'video.movie': + case 'video.episode': + return OgTypFields.video + default: + return OgTypFields.basic + } +} + +export function resolveOpenGraph( + openGraph: Metadata['openGraph'] +): ResolvedOpenGraph { + const url = openGraph + ? typeof openGraph.url === 'string' + ? new URL(openGraph.url) + : openGraph.url + : undefined + + // TODO: improve typing + const resolved: { [x: string]: any } = openGraph || {} + + function assignProps(og: OpenGraph) { + const ogType = og && 'type' in og ? og.type : undefined + const keys = getFieldsByOgType(ogType) + for (const k of keys) { + const key = k as keyof OpenGraph + if (key in og) { + // TODO: fix typing inferring + // @ts-ignore + const value = resolveAsArrayOrUndefined(og[key]) + if (value != null) { + ;(resolved as any)[key] = value + } + } + } + } + + if (openGraph) { + assignProps(openGraph) + } + + if (url) { + resolved.url = url + } + + return resolved as ResolvedOpenGraph +} diff --git a/packages/next/src/lib/metadata/resolve-title.ts b/packages/next/src/lib/metadata/resolve-title.ts new file mode 100644 index 0000000000000..fae87a60b25b7 --- /dev/null +++ b/packages/next/src/lib/metadata/resolve-title.ts @@ -0,0 +1,41 @@ +import type { Metadata } from './types/metadata-interface' +import type { AbsoluteTemplateString } from './types/metadata-types' + +function resolveTitleTemplate(template: string | null, title: string) { + return template ? template.replace(/%s/g, title) : title +} + +export function mergeTitle( + source: T, + stashedTemplate: string | null +) { + const { title } = source + + let resolved + const template = + typeof source.title !== 'string' && + source.title && + 'template' in source.title + ? source.title.template + : null + + if (typeof title === 'string') { + resolved = resolveTitleTemplate(stashedTemplate, title) + } else if (title) { + if ('default' in title) { + resolved = resolveTitleTemplate(stashedTemplate, title.default) + } + if ('absolute' in title && title.absolute) { + resolved = title.absolute + } + } + + const target = source + if (source.title && typeof source.title !== 'string') { + const targetTitle = source.title as AbsoluteTemplateString + targetTitle.template = template + targetTitle.absolute = resolved || '' + } else { + target.title = { absolute: resolved || source.title || '', template } + } +} diff --git a/packages/next/src/lib/metadata/types/alternative-urls-types.ts b/packages/next/src/lib/metadata/types/alternative-urls-types.ts new file mode 100644 index 0000000000000..ad4938df9feb5 --- /dev/null +++ b/packages/next/src/lib/metadata/types/alternative-urls-types.ts @@ -0,0 +1,261 @@ +// Reference: https://hreflang.org/what-is-a-valid-hreflang + +type LangCode = + | 'af-ZA' + | 'am-ET' + | 'ar-AE' + | 'ar-BH' + | 'ar-DZ' + | 'ar-EG' + | 'ar-IQ' + | 'ar-JO' + | 'ar-KW' + | 'ar-LB' + | 'ar-LY' + | 'ar-MA' + | 'arn-CL' + | 'ar-OM' + | 'ar-QA' + | 'ar-SA' + | 'ar-SD' + | 'ar-SY' + | 'ar-TN' + | 'ar-YE' + | 'as-IN' + | 'az-az' + | 'az-Cyrl-AZ' + | 'az-Latn-AZ' + | 'ba-RU' + | 'be-BY' + | 'bg-BG' + | 'bn-BD' + | 'bn-IN' + | 'bo-CN' + | 'br-FR' + | 'bs-Cyrl-BA' + | 'bs-Latn-BA' + | 'ca-ES' + | 'co-FR' + | 'cs-CZ' + | 'cy-GB' + | 'da-DK' + | 'de-AT' + | 'de-CH' + | 'de-DE' + | 'de-LI' + | 'de-LU' + | 'dsb-DE' + | 'dv-MV' + | 'el-CY' + | 'el-GR' + | 'en-029' + | 'en-AU' + | 'en-BZ' + | 'en-CA' + | 'en-cb' + | 'en-GB' + | 'en-IE' + | 'en-IN' + | 'en-JM' + | 'en-MT' + | 'en-MY' + | 'en-NZ' + | 'en-PH' + | 'en-SG' + | 'en-TT' + | 'en-US' + | 'en-ZA' + | 'en-ZW' + | 'es-AR' + | 'es-BO' + | 'es-CL' + | 'es-CO' + | 'es-CR' + | 'es-DO' + | 'es-EC' + | 'es-ES' + | 'es-GT' + | 'es-HN' + | 'es-MX' + | 'es-NI' + | 'es-PA' + | 'es-PE' + | 'es-PR' + | 'es-PY' + | 'es-SV' + | 'es-US' + | 'es-UY' + | 'es-VE' + | 'et-EE' + | 'eu-ES' + | 'fa-IR' + | 'fi-FI' + | 'fil-PH' + | 'fo-FO' + | 'fr-BE' + | 'fr-CA' + | 'fr-CH' + | 'fr-FR' + | 'fr-LU' + | 'fr-MC' + | 'fy-NL' + | 'ga-IE' + | 'gd-GB' + | 'gd-ie' + | 'gl-ES' + | 'gsw-FR' + | 'gu-IN' + | 'ha-Latn-NG' + | 'he-IL' + | 'hi-IN' + | 'hr-BA' + | 'hr-HR' + | 'hsb-DE' + | 'hu-HU' + | 'hy-AM' + | 'id-ID' + | 'ig-NG' + | 'ii-CN' + | 'in-ID' + | 'is-IS' + | 'it-CH' + | 'it-IT' + | 'iu-Cans-CA' + | 'iu-Latn-CA' + | 'iw-IL' + | 'ja-JP' + | 'ka-GE' + | 'kk-KZ' + | 'kl-GL' + | 'km-KH' + | 'kn-IN' + | 'kok-IN' + | 'ko-KR' + | 'ky-KG' + | 'lb-LU' + | 'lo-LA' + | 'lt-LT' + | 'lv-LV' + | 'mi-NZ' + | 'mk-MK' + | 'ml-IN' + | 'mn-MN' + | 'mn-Mong-CN' + | 'moh-CA' + | 'mr-IN' + | 'ms-BN' + | 'ms-MY' + | 'mt-MT' + | 'nb-NO' + | 'ne-NP' + | 'nl-BE' + | 'nl-NL' + | 'nn-NO' + | 'no-no' + | 'nso-ZA' + | 'oc-FR' + | 'or-IN' + | 'pa-IN' + | 'pl-PL' + | 'prs-AF' + | 'ps-AF' + | 'pt-BR' + | 'pt-PT' + | 'qut-GT' + | 'quz-BO' + | 'quz-EC' + | 'quz-PE' + | 'rm-CH' + | 'ro-mo' + | 'ro-RO' + | 'ru-mo' + | 'ru-RU' + | 'rw-RW' + | 'sah-RU' + | 'sa-IN' + | 'se-FI' + | 'se-NO' + | 'se-SE' + | 'si-LK' + | 'sk-SK' + | 'sl-SI' + | 'sma-NO' + | 'sma-SE' + | 'smj-NO' + | 'smj-SE' + | 'smn-FI' + | 'sms-FI' + | 'sq-AL' + | 'sr-BA' + | 'sr-CS' + | 'sr-Cyrl-BA' + | 'sr-Cyrl-CS' + | 'sr-Cyrl-ME' + | 'sr-Cyrl-RS' + | 'sr-Latn-BA' + | 'sr-Latn-CS' + | 'sr-Latn-ME' + | 'sr-Latn-RS' + | 'sr-ME' + | 'sr-RS' + | 'sr-sp' + | 'sv-FI' + | 'sv-SE' + | 'sw-KE' + | 'syr-SY' + | 'ta-IN' + | 'te-IN' + | 'tg-Cyrl-TJ' + | 'th-TH' + | 'tk-TM' + | 'tlh-QS' + | 'tn-ZA' + | 'tr-TR' + | 'tt-RU' + | 'tzm-Latn-DZ' + | 'ug-CN' + | 'uk-UA' + | 'ur-PK' + | 'uz-Cyrl-UZ' + | 'uz-Latn-UZ' + | 'uz-uz' + | 'vi-VN' + | 'wo-SN' + | 'xh-ZA' + | 'yo-NG' + | 'zh-CN' + | 'zh-HK' + | 'zh-MO' + | 'zh-SG' + | 'zh-TW' + | 'zu-ZA' + +type UnmatchedLang = 'x-default' + +type HrefLang = LangCode | UnmatchedLang + +type Languages = { + [s in HrefLang]?: T +} + +export type AlternateURLs = { + canonical?: null | string | URL + languages?: Languages + media?: { + [media: string]: null | string | URL + } + types?: { + [types: string]: null | string | URL + } +} + +export type ResolvedAlternateURLs = { + canonical: null | URL + languages: Languages + media?: { + [media: string]: null | URL + } + types?: { + [types: string]: null | URL + } +} diff --git a/packages/next/src/lib/metadata/types/extra-types.ts b/packages/next/src/lib/metadata/types/extra-types.ts new file mode 100644 index 0000000000000..51001e695d5f2 --- /dev/null +++ b/packages/next/src/lib/metadata/types/extra-types.ts @@ -0,0 +1,84 @@ +// When rendering applink meta tags add a namespace tag before each array instance +// if more than one member exists. +// ref: https://developers.facebook.com/docs/applinks/metadata-reference + +export type AppLinks = { + ios?: AppLinksApple | Array + iphone?: AppLinksApple | Array + ipad?: AppLinksApple | Array + android?: AppLinksAndroid | Array + windows_phone?: AppLinksWindows | Array + windows?: AppLinksWindows | Array + windows_universal?: AppLinksWindows | Array + web?: AppLinksWeb | Array +} +export type AppLinksApple = { + url: string | URL + app_store_id?: string | number + app_name?: string +} +export type AppLinksAndroid = { + package: string + url?: string | URL + class?: string + app_name?: string +} +export type AppLinksWindows = { + url: string | URL + app_id?: string + app_name?: string +} +export type AppLinksWeb = { + url: string | URL + should_fallback?: boolean +} + +// Apple Itunes APp +// https://developer.apple.com/documentation/webkit/promoting_apps_with_smart_app_banners +export type ItunesApp = { + appId: string + appArgument?: string +} + +// Viewport meta structure +// https://developer.mozilla.org/en-US/docs/Web/HTML/Viewport_meta_tag +// intentionally leaving out user-scalable, use a string if you want that behavior +export type Viewport = { + width?: string | number + height?: string | number + initialScale?: number + minimumScale?: number + maximumScale?: number +} + +// Apple Web App +// https://developer.apple.com/library/archive/documentation/AppleApplications/Reference/SafariHTMLRef/Articles/MetaTags.html +// https://developer.apple.com/library/archive/documentation/AppleApplications/Reference/SafariWebContent/ConfiguringWebApplications/ConfiguringWebApplications.html +export type AppleWebApp = { + // default true + capable?: boolean + title?: string + startupImage?: AppleImage | Array + // default "default" + statusBarStyle?: 'default' | 'black' | 'black-translucent' +} +export type AppleImage = string | AppleImageDescriptor +export type AppleImageDescriptor = { + url: string + media?: string +} + +// Format Detection +// This is a poorly specified metadata export type that is supposed to +// control whether the device attempts to conver text that matches +// certain formats into links for action. The most supported example +// is how mobile devices detect phone numbers and make them into links +// that can initiate a phone call +// https://www.goodemailcode.com/email-code/template.html +export type FormatDetection = { + telephone?: boolean + date?: boolean + address?: boolean + email?: boolean + url?: boolean +} diff --git a/packages/next/src/lib/metadata/types/manifest-types.ts b/packages/next/src/lib/metadata/types/manifest-types.ts new file mode 100644 index 0000000000000..aa972679d4ae8 --- /dev/null +++ b/packages/next/src/lib/metadata/types/manifest-types.ts @@ -0,0 +1,3 @@ +export type Manifest = { + // fill this out +} diff --git a/packages/next/src/lib/metadata/types/metadata-interface.ts b/packages/next/src/lib/metadata/types/metadata-interface.ts new file mode 100644 index 0000000000000..6b07373019607 --- /dev/null +++ b/packages/next/src/lib/metadata/types/metadata-interface.ts @@ -0,0 +1,204 @@ +import type { + AlternateURLs, + ResolvedAlternateURLs, +} from './alternative-urls-types' +import type { + AppleWebApp, + AppLinks, + FormatDetection, + ItunesApp, + Viewport, +} from './extra-types' +import type { + AbsoluteTemplateString, + Author, + ColorSchemeEnum, + Icon, + Icons, + ReferrerEnum, + Robots, + TemplateString, + Verification, +} from './metadata-types' +import type { OpenGraph, ResolvedOpenGraph } from './opengraph-types' +import { ResolvedTwitterMetadata, Twitter } from './twitter-types' + +export interface Metadata { + // origin and base path for absolute urls for various metadata links such as + // opengraph-image + metadataBase: null | URL + + // The Document title + title?: null | string | TemplateString + + // The Document description, and optionally the opengraph and twitter descriptions + description?: null | string + + // Standard metadata names + // https://developer.mozilla.org/en-US/docs/Web/HTML/Element/meta/name + applicationName?: null | string | Array + authors?: null | Author | Array + generator?: null | string + // if you provide an array it will be flattened into a single tag with comma separation + keywords?: null | string | Array + referrer?: null | ReferrerEnum + themeColor?: null | string + colorScheme?: null | ColorSchemeEnum + viewport?: null | string | Viewport + creator?: null | string + publisher?: null | string + + // https://developer.mozilla.org/en-US/docs/Web/HTML/Element/meta/name#other_metadata_names + robots?: null | string | Robots + + // The canonical and alternate URLs for this location + alternates: AlternateURLs + + // Defaults to rel="icon" but the Icons type can be used + // to get more specific about rel types + icons?: null | Array | Icons + + openGraph?: null | OpenGraph + + twitter?: null | Twitter + + // common verification tokens + verification?: Verification + + // Apple web app metadata + // https://developer.apple.com/library/archive/documentation/AppleApplications/Reference/SafariHTMLRef/Articles/MetaTags.html + 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 + // be clicked to dial + formatDetection?: null | FormatDetection + + // meta name="apple-itunes-app" + itunes?: null | ItunesApp + + // meta name="abstract" + // A brief description of what this web-page is about. + // Not recommended, superceded by description. + // https://www.metatags.org/all-meta-tags-overview/meta-name-abstract/ + abstract?: null | string + + // Facebook AppLinks + appLinks?: null | AppLinks + + // link rel properties + archives?: null | string | Array + assets?: null | string | Array + bookmarks?: null | string | Array // This is technically against HTML spec but is used in wild + + // meta name properties + category?: null | string + classification?: null | string + + // Arbitrary name/value pairs + other?: { + [name: string]: string | number | Array + } + + /** + * Deprecated options that have a preferred method + * */ + // Use appWebApp to configure apple-mobile-web-app-capable which provides + // https://www.appsloveworld.com/coding/iphone/11/difference-between-apple-mobile-web-app-capable-and-apple-touch-fullscreen-ipho + 'apple-touch-fullscreen'?: never + + // Obsolete since iOS 7. use icons.apple or "app-touch-icon" instead + // https://web.dev/apple-touch-icon/ + 'apple-touch-icon-precomposed'?: never +} + +export interface ResolvedMetadata { + // origin and base path for absolute urls for various metadata links such as + // opengraph-image + metadataBase: null | URL + + // The Document title and template if defined + title: null | AbsoluteTemplateString + + // The Document description, and optionally the opengraph and twitter descriptions + description: null | string + + // Standard metadata names + // https://developer.mozilla.org/en-US/docs/Web/HTML/Element/meta/name + applicationName: null | string + authors: null | Array + generator: null | string + // if you provide an array it will be flattened into a single tag with comma separation + keywords: null | Array + referrer: null | ReferrerEnum + themeColor: null | string + colorScheme: null | ColorSchemeEnum + viewport: null | string + creator: null | string + publisher: null | string + + // https://developer.mozilla.org/en-US/docs/Web/HTML/Element/meta/name#other_metadata_names + robots: null | string + + // The canonical and alternate URLs for this location + alternates: ResolvedAlternateURLs + + // Defaults to rel="icon" but the Icons type can be used + // to get more specific about rel types + icons: null | Icons + + openGraph: null | ResolvedOpenGraph + + twitter: null | ResolvedTwitterMetadata + + // common verification tokens + verification: Verification + + // Apple web app metadata + // https://developer.apple.com/library/archive/documentation/AppleApplications/Reference/SafariHTMLRef/Articles/MetaTags.html + 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 + // be clicked to dial + formatDetection: null | FormatDetection + + // meta name="apple-itunes-app" + itunes: null | ItunesApp + + // meta name="abstract" + // A brief description of what this web-page is about. + // Not recommended, superceded by description. + // https://www.metatags.org/all-meta-tags-overview/meta-name-abstract/ + abstract: null | string + + // Facebook AppLinks + appLinks: null | AppLinks + + // link rel properties + archives: null | Array + assets: null | Array + bookmarks: null | Array // This is technically against HTML spec but is used in wild + + // meta name properties + category: null | string + classification: null | string + + // Arbitrary name/value pairs + other: { + [name: string]: string | number | Array + } + + /** + * Deprecated options that have a preferred method + * */ + // Use appWebApp to configure apple-mobile-web-app-capable which provides + // https://www.appsloveworld.com/coding/iphone/11/difference-between-apple-mobile-web-app-capable-and-apple-touch-fullscreen-ipho + 'apple-touch-fullscreen'?: never + + // Obsolete since iOS 7. use icons.apple or "app-touch-icon" instead + // https://web.dev/apple-touch-icon/ + 'apple-touch-icon-precomposed'?: never +} + +export type ResolvingMetadata = Promise diff --git a/packages/next/src/lib/metadata/types/metadata-types.ts b/packages/next/src/lib/metadata/types/metadata-types.ts new file mode 100644 index 0000000000000..fbd3b502cae68 --- /dev/null +++ b/packages/next/src/lib/metadata/types/metadata-types.ts @@ -0,0 +1,93 @@ +/** + * + * Metadata types + * + */ +export type TemplateString = + | DefaultTemplateString + | AbsoluteTemplateString + | AbsoluteString +export type DefaultTemplateString = { + default: string + template: string +} +export type AbsoluteTemplateString = { + absolute: string + template: string | null +} +export type AbsoluteString = { + absolute: string +} + +export type Author = { + // renders as + // rel="shortcut icon" + shortcut?: Icon | Array + // rel="apple-touch-icon" + apple?: Icon | Array + // rel inferred from descriptor, defaults to "icon" + other?: Icon | Array +} + +export type Verification = { + google?: null | string | number | Array + yahoo?: null | string | number | Array + // if you ad-hoc additional verification + other?: { + [name: string]: string | number | Array + } +} diff --git a/packages/next/src/lib/metadata/types/opengraph-types.ts b/packages/next/src/lib/metadata/types/opengraph-types.ts new file mode 100644 index 0000000000000..3b90c1c87874d --- /dev/null +++ b/packages/next/src/lib/metadata/types/opengraph-types.ts @@ -0,0 +1,267 @@ +import type { AbsoluteTemplateString, TemplateString } from './metadata-types' + +export type OpenGraphType = + | 'article' + | 'book' + | 'music.song' + | 'music.album' + | 'music.playlist' + | 'music.radio_station' + | 'profile' + | 'website' + | 'video.tv_show' + | 'video.other' + | 'video.movie' + | 'video.episode' + +export type OpenGraph = + | OpenGraphWebsite + | OpenGraphArticle + | OpenGraphBook + | OpenGraphProfile + | OpenGraphMusicSong + | OpenGraphMusicAlbum + | OpenGraphMusicPlaylist + | OpenGraphRadioStation + | OpenGraphVideoMovie + | OpenGraphVideoEpisode + | OpenGraphVideoTVShow + | OpenGraphVideoOther + | OpenGraphMetadata + +// update this type to reflect actual locales +type Locale = string + +type OpenGraphMetadata = { + determiner?: 'a' | 'an' | 'the' | 'auto' | '' + title?: TemplateString + description?: string + emails?: string | Array + phoneNumbers?: string | Array + faxNumbers?: string | Array + siteName?: string + locale?: Locale + alternateLocale?: Locale | Array + images?: OGImage | Array + audio?: OGAudio | Array + videos?: OGVideo | Array + url?: string | URL + countryName?: string + ttl?: number +} +type OpenGraphWebsite = OpenGraphMetadata & { + type: 'website' +} +type OpenGraphArticle = OpenGraphMetadata & { + type: 'article' + publishedTime?: string // datetime + modifiedTime?: string // datetime + expirationTime?: string // datetime + authors?: null | string | URL | Array + section?: null | string + tags?: null | string | Array +} +type OpenGraphBook = OpenGraphMetadata & { + type: 'book' + isbn?: null | string + releaseDate?: null | string // datetime + authors?: null | string | URL | Array + tags?: null | string | Array +} +type OpenGraphProfile = OpenGraphMetadata & { + type: 'profile' + firstName?: null | string + lastName?: null | string + username?: null | string + gender?: null | string +} +type OpenGraphMusicSong = OpenGraphMetadata & { + type: 'music.song' + duration?: null | number + albums?: null | string | URL | OGAlbum | Array + musicians?: null | string | URL | Array +} +type OpenGraphMusicAlbum = OpenGraphMetadata & { + type: 'music.album' + songs?: null | string | URL | OGSong | Array + musicians?: null | string | URL | Array + releaseDate?: null | string // datetime +} +type OpenGraphMusicPlaylist = OpenGraphMetadata & { + type: 'music.playlist' + songs?: null | string | URL | OGSong | Array + creators?: null | string | URL | Array +} +type OpenGraphRadioStation = OpenGraphMetadata & { + type: 'music.radio_station' + creators?: null | string | URL | Array +} +type OpenGraphVideoMovie = OpenGraphMetadata & { + type: 'video.movie' + actors?: null | string | URL | OGActor | Array + directors?: null | string | URL | Array + writers?: null | string | URL | Array + duration?: null | number + releaseDate?: null | string // datetime + tags?: null | string | Array +} +type OpenGraphVideoEpisode = OpenGraphMetadata & { + type: 'video.episode' + actors?: null | string | URL | OGActor | Array + directors?: null | string | URL | Array + writers?: null | string | URL | Array + duration?: null | number + releaseDate?: null | string // datetime + tags?: null | string | Array + series?: null | string | URL +} +type OpenGraphVideoTVShow = OpenGraphMetadata & { + type: 'video.tv_show' +} +type OpenGraphVideoOther = OpenGraphMetadata & { + type: 'video.other' +} + +type OGImage = string | OGImageDescriptor | URL +type OGImageDescriptor = { + url: string | URL + secureUrl?: string | URL + alt?: string + type?: string + width?: string | number + height?: string | number +} +type OGAudio = string | OGAudioDescriptor | URL +type OGAudioDescriptor = { + url: string | URL + secure_url?: string | URL + type?: string +} +type OGVideo = string | OGVideoDescriptor | URL +type OGVideoDescriptor = { + url: string | URL + secureUrl?: string | URL + type?: string + width?: string | number + height?: string | number +} + +export type ResolvedOpenGraph = + | ResolvedOpenGraphWebsite + | ResolvedOpenGraphArticle + | ResolvedOpenGraphBook + | ResolvedOpenGraphProfile + | ResolvedOpenGraphMusicSong + | ResolvedOpenGraphMusicAlbum + | ResolvedOpenGraphMusicPlaylist + | ResolvedOpenGraphRadioStation + | ResolvedOpenGraphVideoMovie + | ResolvedOpenGraphVideoEpisode + | ResolvedOpenGraphVideoTVShow + | ResolvedOpenGraphVideoOther + | ResolvedOpenGraphMetadata + +type ResolvedOpenGraphMetadata = { + determiner?: 'a' | 'an' | 'the' | 'auto' | '' + title?: AbsoluteTemplateString + description?: string + emails?: Array + phoneNumbers?: Array + faxNumbers?: Array + siteName?: string + locale?: Locale + alternateLocale?: Array + images?: Array + audio?: Array + videos?: Array + url?: URL + countryName?: string + ttl?: number +} +type ResolvedOpenGraphWebsite = ResolvedOpenGraphMetadata & { + type: 'website' +} +type ResolvedOpenGraphArticle = ResolvedOpenGraphMetadata & { + type: 'article' + publishedTime?: string // datetime + modifiedTime?: string // datetime + expirationTime?: string // datetime + authors?: Array + section?: string + tags?: Array +} +type ResolvedOpenGraphBook = ResolvedOpenGraphMetadata & { + type: 'book' + isbn?: string + releaseDate?: string // datetime + authors?: Array + tags?: Array +} +type ResolvedOpenGraphProfile = ResolvedOpenGraphMetadata & { + type: 'profile' + firstName?: string + lastName?: string + username?: string + gender?: string +} +type ResolvedOpenGraphMusicSong = ResolvedOpenGraphMetadata & { + type: 'music.song' + duration?: number + albums?: Array + musicians?: Array +} +type ResolvedOpenGraphMusicAlbum = ResolvedOpenGraphMetadata & { + type: 'music.album' + songs?: Array + musicians?: Array + releaseDate?: string // datetime +} +type ResolvedOpenGraphMusicPlaylist = ResolvedOpenGraphMetadata & { + type: 'music.playlist' + songs?: Array + creators?: Array +} +type ResolvedOpenGraphRadioStation = ResolvedOpenGraphMetadata & { + type: 'music.radio_station' + creators?: Array +} +type ResolvedOpenGraphVideoMovie = ResolvedOpenGraphMetadata & { + type: 'video.movie' + actors?: Array + directors?: Array + writers?: Array + duration?: number + releaseDate?: string // datetime + tags?: Array +} +type ResolvedOpenGraphVideoEpisode = ResolvedOpenGraphMetadata & { + type: 'video.episode' + actors?: Array + directors?: Array + writers?: Array + duration?: number + releaseDate?: string // datetime + tags?: Array + series?: string | URL +} +type ResolvedOpenGraphVideoTVShow = ResolvedOpenGraphMetadata & { + type: 'video.tv_show' +} +type ResolvedOpenGraphVideoOther = ResolvedOpenGraphMetadata & { + type: 'video.other' +} + +type OGSong = { + url: string | URL + disc?: number + track?: number +} +type OGAlbum = { + url: string | URL + disc?: number + track?: number +} +type OGActor = { + url: string | URL + role?: string +} diff --git a/packages/next/src/lib/metadata/types/twitter-types.ts b/packages/next/src/lib/metadata/types/twitter-types.ts new file mode 100644 index 0000000000000..f0666d9f64ebd --- /dev/null +++ b/packages/next/src/lib/metadata/types/twitter-types.ts @@ -0,0 +1,66 @@ +import type { AbsoluteTemplateString, TemplateString } from './metadata-types' + +export type Twitter = + | TwitterSummary + | TwitterSummaryLargeImage + | TwitterPlayer + | TwitterApp + | TwitterMetadata + +type TwitterMetadata = { + // defaults to card="summary" + site?: string // username for account associated to the site itself + 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 + images?: TwitterImage | Array +} +type TwitterSummary = TwitterMetadata & { + card: 'summary' +} +type TwitterSummaryLargeImage = TwitterMetadata & { + card: 'summary_large_image' +} +type TwitterPlayer = TwitterMetadata & { + card: 'player' + players: TwitterPlayerDescriptor | Array +} +type TwitterApp = TwitterMetadata & { + card: 'app' + app: TwitterAppDescriptor +} +type TwitterAppDescriptor = { + id: { + iphone?: string | number + ipad?: string | number + googleplay?: string + } + url?: { + iphone?: string | URL + ipad?: string | URL + googleplay?: string | URL + } + country?: string +} + +type TwitterImage = string | TwitterImageDescriptor | URL +type TwitterImageDescriptor = { + url: string | URL + secureUrl?: string | URL + alt?: string + type?: string + width?: string | number + height?: string | number +} +type TwitterPlayerDescriptor = { + playerUrl: string | URL + streamUrl: string | URL + width: number + height: number +} + +export type ResolvedTwitterMetadata = Omit & { + title: AbsoluteTemplateString | null +} diff --git a/packages/next/src/lib/metadata/ui.tsx b/packages/next/src/lib/metadata/ui.tsx new file mode 100644 index 0000000000000..b79dfca145c17 --- /dev/null +++ b/packages/next/src/lib/metadata/ui.tsx @@ -0,0 +1,19 @@ +import React from 'react' + +import type { ResolvedMetadata } from './types/metadata-interface' +import { ResolvedBasicMetadata } from './generate/basic' +import { ResolvedAlternatesMetadata } from './generate/alternate' +import { ResolvedOpenGraphMetadata } from './generate/opengraph' +import { resolveMetadata } from './resolve-metadata' + +// Generate the actual React elements from the resolved metadata. +export async function Metadata({ metadata }: { metadata: any }) { + const resolved: ResolvedMetadata = await resolveMetadata(metadata) + return ( + <> + + + + + ) +} diff --git a/packages/next/src/server/app-render.tsx b/packages/next/src/server/app-render.tsx index 7952132a2a35d..d8c3aacc3b67e 100644 --- a/packages/next/src/server/app-render.tsx +++ b/packages/next/src/server/app-render.tsx @@ -45,8 +45,8 @@ import { RSC, } from '../client/components/app-router-headers' import type { StaticGenerationAsyncStorage } from '../client/components/static-generation-async-storage' -import { DefaultHead } from '../client/components/head' import { formatServerError } from '../lib/format-server-error' +import { Metadata } from '../lib/metadata/ui' 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' @@ -975,6 +975,12 @@ export async function renderToHTMLOrFlight( */ const loaderTree: LoaderTree = ComponentMod.tree + /** + * The metadata items array created in next-app-loader with all relevant information + * that we need to resolve the final metadata. + */ + const metadataItems = ComponentMod.metadata + stripInternalQueries(query) const LayoutRouter = @@ -1058,8 +1064,7 @@ export async function renderToHTMLOrFlight( async function resolveHead( [segment, parallelRoutes, { head }]: LoaderTree, - parentParams: { [key: string]: any }, - isRootHead: boolean + parentParams: { [key: string]: any } ): Promise { // Handle dynamic segment params. const segmentParam = getDynamicParamFromSegment(segment) @@ -1077,7 +1082,7 @@ export async function renderToHTMLOrFlight( parentParams for (const key in parallelRoutes) { const childTree = parallelRoutes[key] - const returnedHead = await resolveHead(childTree, currentParams, false) + const returnedHead = await resolveHead(childTree, currentParams) if (returnedHead) { return returnedHead } @@ -1086,8 +1091,6 @@ export async function renderToHTMLOrFlight( if (head) { const Head = await interopDefault(await head[0]()) return - } else if (isRootHead) { - return } return null @@ -1694,7 +1697,7 @@ export async function renderToHTMLOrFlight( return [actualSegment] } - const rscPayloadHead = await resolveHead(loaderTree, {}, true) + const rscPayloadHead = await resolveHead(loaderTree, {}) // Flight data that is going to be passed to the browser. // Currently a single item array but in the future multiple patches might be combined in a single request. const flightData: FlightData = [ @@ -1705,7 +1708,13 @@ export async function renderToHTMLOrFlight( parentParams: {}, flightRouterState: providedFlightRouterState, isFirst: true, - rscPayloadHead, + rscPayloadHead: ( + <> + {/* @ts-expect-error allow to use async server component */} + + {rscPayloadHead} + + ), injectedCSS: new Set(), rootLayoutIncluded: false, }) @@ -1772,7 +1781,7 @@ export async function renderToHTMLOrFlight( } : {} - const initialHead = await resolveHead(loaderTree, {}, true) + const initialHead = await resolveHead(loaderTree, {}) /** * A new React Component that renders the provided React Component @@ -1789,18 +1798,27 @@ export async function renderToHTMLOrFlight( injectedCSS: new Set(), rootLayoutIncluded: false, }) + const initialTree = createFlightRouterStateFromLoaderTree(loaderTree) return ( - - - + <> + + {/* @ts-expect-error allow to use async server component */} + + {initialHead} + + } + globalErrorComponent={GlobalError} + > + + + ) }, ComponentMod, diff --git a/test/e2e/app-dir/app/index.test.ts b/test/e2e/app-dir/app/index.test.ts index b9ff9f6bba433..b8ba84c1def02 100644 --- a/test/e2e/app-dir/app/index.test.ts +++ b/test/e2e/app-dir/app/index.test.ts @@ -102,7 +102,7 @@ createNextDescribe( it('should pass props from getServerSideProps in root layout', async () => { const $ = await next.render$('/dashboard') - expect($('title').text()).toBe('hello world') + expect($('title').first().text()).toBe('hello world') }) it('should serve from pages', async () => { diff --git a/test/e2e/app-dir/metadata/app/alternate/page.js b/test/e2e/app-dir/metadata/app/alternate/page.js new file mode 100644 index 0000000000000..25777130e9a6f --- /dev/null +++ b/test/e2e/app-dir/metadata/app/alternate/page.js @@ -0,0 +1,19 @@ +export default function Page() { + return

hello

+} + +export const metadata = { + alternates: { + canonical: 'https://example.com', + languages: { + 'en-US': 'https://example.com/en-US', + 'de-DE': 'https://example.com/de-DE', + }, + media: { + 'only screen and (max-width: 600px)': 'https://example.com/mobile', + }, + types: { + 'application/rss+xml': 'https://example.com/rss', + }, + }, +} diff --git a/test/e2e/app-dir/metadata/app/basic/page.js b/test/e2e/app-dir/metadata/app/basic/page.js new file mode 100644 index 0000000000000..224dd10988942 --- /dev/null +++ b/test/e2e/app-dir/metadata/app/basic/page.js @@ -0,0 +1,25 @@ +import Link from 'next/link' + +export default function Page() { + return ( +
+ + to index + +
+ ) +} + +export const metadata = { + generator: 'next.js', + applicationName: 'test', + referrer: 'origin-when-crossorigin', + keywords: ['next.js', 'react', 'javascript'], + authors: ['John Doe', 'Jane Doe'], + themeColor: 'cyan', + colorScheme: 'dark', + viewport: 'width=device-width, initial-scale=1, shrink-to-fit=no', + creator: 'shu', + publisher: 'vercel', + robots: 'index, follow', +} diff --git a/test/e2e/app-dir/metadata/app/layout.js b/test/e2e/app-dir/metadata/app/layout.js new file mode 100644 index 0000000000000..e427799cce6f7 --- /dev/null +++ b/test/e2e/app-dir/metadata/app/layout.js @@ -0,0 +1,13 @@ +export default function Layout({ children }) { + return ( + + + {children} + + ) +} + +export const metadata = { + title: 'this is the layout title', + description: 'this is the layout description', +} diff --git a/test/e2e/app-dir/metadata/app/opengraph/article/page.js b/test/e2e/app-dir/metadata/app/opengraph/article/page.js new file mode 100644 index 0000000000000..4b92b111d6610 --- /dev/null +++ b/test/e2e/app-dir/metadata/app/opengraph/article/page.js @@ -0,0 +1,13 @@ +export default function Page() { + return

hello

+} + +export const metadata = { + openGraph: { + title: 'My custom title', + description: 'My custom description', + type: 'article', + publishedTime: '2023-01-01T00:00:00.000Z', + authors: ['author1', 'author2', 'author3'], + }, +} diff --git a/test/e2e/app-dir/metadata/app/opengraph/page.js b/test/e2e/app-dir/metadata/app/opengraph/page.js new file mode 100644 index 0000000000000..970a521bf382a --- /dev/null +++ b/test/e2e/app-dir/metadata/app/opengraph/page.js @@ -0,0 +1,27 @@ +export default function Page() { + return

hello

+} + +export const metadata = { + openGraph: { + title: 'My custom title', + description: 'My custom description', + url: 'https://example.com', + siteName: 'My custom site name', + images: [ + { + url: 'https://example.com/image.png', + width: 800, + height: 600, + }, + { + url: 'https://example.com/image2.png', + width: 1800, + height: 1600, + alt: 'My custom alt', + }, + ], + locale: 'en-US', + type: 'website', + }, +} diff --git a/test/e2e/app-dir/metadata/app/page.js b/test/e2e/app-dir/metadata/app/page.js new file mode 100644 index 0000000000000..202d1179a5c86 --- /dev/null +++ b/test/e2e/app-dir/metadata/app/page.js @@ -0,0 +1,23 @@ +import Link from 'next/link' + +export default function Page() { + return ( + <> +

index page

+ + + to /basic + +
+ + + to /title + +
+ + ) +} + +export const metadata = { + title: 'index page', +} 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.js new file mode 100644 index 0000000000000..426364f16fb38 --- /dev/null +++ b/test/e2e/app-dir/metadata/app/title-template/extra/inner/page.js @@ -0,0 +1,7 @@ +export default function Page() { + return

hello

+} + +export const metadata = { + title: 'Inner Page', +} 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.js new file mode 100644 index 0000000000000..93f67bdbf497e --- /dev/null +++ b/test/e2e/app-dir/metadata/app/title-template/extra/layout.js @@ -0,0 +1,9 @@ +export default function Layout(props) { + return props.children +} + +export const metadata = { + title: { + template: '%s | Extra Layout', + }, +} 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.js new file mode 100644 index 0000000000000..ea77ded22896d --- /dev/null +++ b/test/e2e/app-dir/metadata/app/title-template/extra/page.js @@ -0,0 +1,7 @@ +export default function Page() { + return

hello

+} + +export const metadata = { + title: 'Extra Page', +} diff --git a/test/e2e/app-dir/metadata/app/title-template/layout.js b/test/e2e/app-dir/metadata/app/title-template/layout.js new file mode 100644 index 0000000000000..adec3c2eb8874 --- /dev/null +++ b/test/e2e/app-dir/metadata/app/title-template/layout.js @@ -0,0 +1,9 @@ +export default function Layout(props) { + return props.children +} + +export const metadata = { + title: { + template: '%s | Layout', + }, +} diff --git a/test/e2e/app-dir/metadata/app/title-template/page.js b/test/e2e/app-dir/metadata/app/title-template/page.js new file mode 100644 index 0000000000000..2fbc7681df1ea --- /dev/null +++ b/test/e2e/app-dir/metadata/app/title-template/page.js @@ -0,0 +1,7 @@ +export default function Page() { + return

hello

+} + +export const metadata = { + title: 'Page', +} diff --git a/test/e2e/app-dir/metadata/app/title/page.js b/test/e2e/app-dir/metadata/app/title/page.js new file mode 100644 index 0000000000000..9e63975b66874 --- /dev/null +++ b/test/e2e/app-dir/metadata/app/title/page.js @@ -0,0 +1,15 @@ +import Link from 'next/link' + +export default function Page() { + return ( +
+ + to index + +
+ ) +} + +export const metadata = { + title: 'this is the page title', +} diff --git a/test/e2e/app-dir/metadata/app/viewport/object/page.js b/test/e2e/app-dir/metadata/app/viewport/object/page.js new file mode 100644 index 0000000000000..e82899ae607df --- /dev/null +++ b/test/e2e/app-dir/metadata/app/viewport/object/page.js @@ -0,0 +1,11 @@ +export default function Page() { + return

viewport

+} + +export const metadata = { + viewport: { + width: 'device-width', + initialScale: 1, + maximumScale: 1, + }, +} diff --git a/test/e2e/app-dir/metadata/metadata.test.ts b/test/e2e/app-dir/metadata/metadata.test.ts new file mode 100644 index 0000000000000..1d805490533d8 --- /dev/null +++ b/test/e2e/app-dir/metadata/metadata.test.ts @@ -0,0 +1,214 @@ +import { createNextDescribe } from 'e2e-utils' + +createNextDescribe( + 'app dir - metadata', + { + files: __dirname, + }, + ({ next, isNextDeploy }) => { + describe('metadata', () => { + if (isNextDeploy) { + it('should skip for deploy currently', () => {}) + return + } + async function checkMeta( + browser, + name, + content, + property = 'property', + tag = 'meta', + field = 'content' + ) { + const values = await browser.eval( + `[...document.querySelectorAll('${tag}[${property}="${name}"]')].map((el) => el.${field})` + ) + if (Array.isArray(content)) { + expect(values).toEqual(content) + } else { + console.log('expect', values[0], 'toContain', content) + expect(values[0]).toContain(content) + } + } + + describe('basic', () => { + it('should support title and description', async () => { + const browser = await next.browser('/title') + expect(await browser.eval(`document.title`)).toBe( + 'this is the page title' + ) + await checkMeta( + browser, + 'description', + 'this is the layout description', + 'name' + ) + }) + + it('should support title template', async () => { + const browser = await next.browser('/title-template') + expect(await browser.eval(`document.title`)).toBe('Page | Layout') + }) + + it('should support stashed title in one layer of page and layout', async () => { + const browser = await next.browser('/title-template/extra') + expect(await browser.eval(`document.title`)).toBe( + 'Extra Page | Extra Layout' + ) + }) + + it('should support stashed title in two layers of page and layout', async () => { + const browser = await next.browser('/title-template/extra/inner') + expect(await browser.eval(`document.title`)).toBe( + 'Inner Page | Extra Layout' + ) + }) + + 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( + browser, + 'referrer', + 'origin-when-crossorigin', + 'name' + ) + await checkMeta( + browser, + 'keywords', + 'next.js,react,javascript', + 'name' + ) + 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( + browser, + 'viewport', + 'width=device-width, initial-scale=1, shrink-to-fit=no', + 'name' + ) + await checkMeta(browser, 'creator', 'shu', 'name') + await checkMeta(browser, 'publisher', 'vercel', 'name') + await checkMeta(browser, 'robots', 'index, follow', 'name') + }) + + it('should support object viewport', async () => { + const browser = await next.browser('/viewport/object') + await checkMeta( + browser, + 'viewport', + 'width=device-width, initial-scale=1, maximum-scale=1', + 'name' + ) + }) + + it('should support alternate tags', async () => { + const browser = await next.browser('/alternate') + await checkMeta( + browser, + 'canonical', + 'https://example.com', + 'rel', + 'link', + 'href' + ) + await checkMeta( + browser, + 'en-US', + 'https://example.com/en-US', + 'hreflang', + 'link', + 'href' + ) + await checkMeta( + browser, + 'de-DE', + 'https://example.com/de-DE', + 'hreflang', + 'link', + 'href' + ) + await checkMeta( + browser, + 'only screen and (max-width: 600px)', + 'https://example.com/mobile', + 'media', + 'link', + 'href' + ) + await checkMeta( + browser, + 'application/rss+xml', + 'https://example.com/rss', + 'type', + 'link', + 'href' + ) + }) + + it('should apply metadata when navigating client-side', async () => { + const browser = await next.browser('/') + + const getTitle = () => browser.elementByCss('title').text() + + expect(await getTitle()).toBe('index page') + await browser + .elementByCss('#to-basic') + .click() + .waitForElementByCss('#basic', 2000) + + await checkMeta( + browser, + 'referrer', + 'origin-when-crossorigin', + 'name' + ) + await browser.back().waitForElementByCss('#index', 2000) + expect(await getTitle()).toBe('index page') + await browser + .elementByCss('#to-title') + .click() + .waitForElementByCss('#title', 2000) + expect(await getTitle()).toBe('this is the page title') + }) + }) + + 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', [ + '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') + }) + + 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( + browser, + 'article:published_time', + '2023-01-01T00:00:00.000Z' + ) + await checkMeta(browser, 'article:author', [ + 'author1', + 'author2', + 'author3', + ]) + }) + }) + }) + } +) diff --git a/test/e2e/app-dir/metadata/next.config.js b/test/e2e/app-dir/metadata/next.config.js new file mode 100644 index 0000000000000..8e2a6c3691744 --- /dev/null +++ b/test/e2e/app-dir/metadata/next.config.js @@ -0,0 +1,3 @@ +module.exports = { + experimental: { appDir: true }, +} diff --git a/test/e2e/app-dir/rsc-basic/rsc-basic.test.ts b/test/e2e/app-dir/rsc-basic/rsc-basic.test.ts index 1c9302892594e..314e932e7bef3 100644 --- a/test/e2e/app-dir/rsc-basic/rsc-basic.test.ts +++ b/test/e2e/app-dir/rsc-basic/rsc-basic.test.ts @@ -103,9 +103,11 @@ describe('app dir - rsc basics', () => { // should have only 1 DOCTYPE expect(homeHTML).toMatch(/^') expect(homeHTML).toContain( - '' + '' ) + expect(homeHTML).toContain('component:index.server') expect(homeHTML).toContain('header:test-util')