Support relative path for metadata alternates urls (#47743)
Allow to use relative paths which starting with dot (e.g. `./[paths]`) for urls under `metadata.alternates`. This allow user to simplify setting `canonical` or other such like `languages` once in root layout with `metadataBase` and a simple relative path, then next.js will resolve it with current pathname of the page. For example ```js export const metadata = { metadataBase: new URL('https://mydomain.com'), alternates: { canonical: './' } } ``` Then: for page `/` it will generate `https://mydomain.com`; for page `/about` it will generate `https://mydomain.com/about` as your cononical url Closes NEXT-897 ### Minor changes - always remove trailing slash for `URL.href`
This commit is contained in:
parent
e1a397d750
commit
04bfb314e0
16 changed files with 222 additions and 106 deletions
|
@ -167,6 +167,7 @@ export default async function exportPage({
|
|||
try {
|
||||
const { query: originalQuery = {} } = pathMap
|
||||
const { page } = pathMap
|
||||
const pathname = normalizeAppPath(page)
|
||||
const isAppDir = (pathMap as any)._isAppDir
|
||||
const isDynamicError = (pathMap as any)._isDynamicError
|
||||
const filePath = normalizePagePath(path)
|
||||
|
@ -469,7 +470,7 @@ export default async function exportPage({
|
|||
const result = await renderToHTMLOrFlight(
|
||||
req as any,
|
||||
res as any,
|
||||
isNotFoundPage ? '/404' : page,
|
||||
isNotFoundPage ? '/404' : pathname,
|
||||
query,
|
||||
curRenderOpts as any
|
||||
)
|
||||
|
|
|
@ -2,6 +2,7 @@ import type { ResolvedMetadata } from '../types/metadata-interface'
|
|||
|
||||
import React from 'react'
|
||||
import { AlternateLinkDescriptor } from '../types/alternative-urls-types'
|
||||
import { resolveStringUrl } from '../resolvers/resolve-url'
|
||||
|
||||
function AlternateLink({
|
||||
descriptor,
|
||||
|
@ -14,7 +15,7 @@ function AlternateLink({
|
|||
<link
|
||||
{...props}
|
||||
{...(descriptor.title && { title: descriptor.title })}
|
||||
href={descriptor.url.toString()}
|
||||
href={resolveStringUrl(descriptor.url)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -2,6 +2,7 @@ import type { ResolvedMetadata } from '../types/metadata-interface'
|
|||
|
||||
import React from 'react'
|
||||
import { Meta, MultiMeta } from './meta'
|
||||
import { resolveStringUrl } from '../resolvers/resolve-url'
|
||||
|
||||
export function BasicMetadata({ metadata }: { metadata: ResolvedMetadata }) {
|
||||
return (
|
||||
|
@ -15,13 +16,15 @@ export function BasicMetadata({ metadata }: { metadata: ResolvedMetadata }) {
|
|||
{metadata.authors
|
||||
? metadata.authors.map((author, index) => (
|
||||
<React.Fragment key={index}>
|
||||
{author.url && <link rel="author" href={author.url.toString()} />}
|
||||
{author.url && (
|
||||
<link rel="author" href={resolveStringUrl(author.url)} />
|
||||
)}
|
||||
<Meta name="author" content={author.name} />
|
||||
</React.Fragment>
|
||||
))
|
||||
: null}
|
||||
{metadata.manifest ? (
|
||||
<link rel="manifest" href={metadata.manifest.toString()} />
|
||||
<link rel="manifest" href={resolveStringUrl(metadata.manifest)} />
|
||||
) : null}
|
||||
<Meta name="generator" content={metadata.generator} />
|
||||
<Meta name="keywords" content={metadata.keywords?.join(',')} />
|
||||
|
|
|
@ -2,14 +2,12 @@ import type { ResolvedMetadata } from '../types/metadata-interface'
|
|||
import type { Icon, IconDescriptor } from '../types/metadata-types'
|
||||
|
||||
import React from 'react'
|
||||
|
||||
const resolveUrl = (url: string | URL) =>
|
||||
typeof url === 'string' ? url : url.toString()
|
||||
import { resolveStringUrl } from '../resolvers/resolve-url'
|
||||
|
||||
function IconDescriptorLink({ icon }: { icon: IconDescriptor }) {
|
||||
const { url, rel = 'icon', ...props } = icon
|
||||
|
||||
return <link rel={rel} href={resolveUrl(url)} {...props} />
|
||||
return <link rel={rel} href={resolveStringUrl(url)} {...props} />
|
||||
}
|
||||
|
||||
function IconLink({ rel, icon }: { rel?: string; icon: Icon }) {
|
||||
|
@ -17,7 +15,7 @@ function IconLink({ rel, icon }: { rel?: string; icon: Icon }) {
|
|||
if (rel) icon.rel = rel
|
||||
return <IconDescriptorLink icon={icon} />
|
||||
} else {
|
||||
const href = resolveUrl(icon)
|
||||
const href = resolveStringUrl(icon)
|
||||
return <link rel={rel} href={href} />
|
||||
}
|
||||
}
|
||||
|
|
|
@ -16,8 +16,14 @@ import { IconsMetadata } from './generate/icons'
|
|||
import { accumulateMetadata, MetadataItems } from './resolve-metadata'
|
||||
|
||||
// Generate the actual React elements from the resolved metadata.
|
||||
export async function MetadataTree({ metadata }: { metadata: MetadataItems }) {
|
||||
const resolved = await accumulateMetadata(metadata)
|
||||
export async function MetadataTree({
|
||||
metadata,
|
||||
pathname,
|
||||
}: {
|
||||
metadata: MetadataItems
|
||||
pathname: string
|
||||
}) {
|
||||
const resolved = await accumulateMetadata(metadata, pathname)
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
|
@ -1,6 +1,13 @@
|
|||
import { accumulateMetadata, MetadataItems } from './resolve-metadata'
|
||||
import {
|
||||
accumulateMetadata as originAccumulateMetadata,
|
||||
MetadataItems,
|
||||
} from './resolve-metadata'
|
||||
import { Metadata } from './types/metadata-interface'
|
||||
|
||||
function accumulateMetadata(metadataItems: MetadataItems) {
|
||||
return originAccumulateMetadata(metadataItems, '/test')
|
||||
}
|
||||
|
||||
describe('accumulateMetadata', () => {
|
||||
describe('typing', () => {
|
||||
it('should support both sync and async metadata', async () => {
|
||||
|
|
|
@ -72,16 +72,23 @@ function mergeStaticMetadata(
|
|||
}
|
||||
|
||||
// Merge the source metadata into the resolved target metadata.
|
||||
function merge(
|
||||
target: ResolvedMetadata,
|
||||
source: Metadata | null,
|
||||
staticFilesMetadata: StaticMetadata,
|
||||
function merge({
|
||||
pathname,
|
||||
target,
|
||||
source,
|
||||
staticFilesMetadata,
|
||||
titleTemplates,
|
||||
}: {
|
||||
pathname: string
|
||||
target: ResolvedMetadata
|
||||
source: Metadata | null
|
||||
staticFilesMetadata: StaticMetadata
|
||||
titleTemplates: {
|
||||
title: string | null
|
||||
twitter: string | null
|
||||
openGraph: string | null
|
||||
}
|
||||
) {
|
||||
}) {
|
||||
// If there's override metadata, prefer it otherwise fallback to the default metadata.
|
||||
const metadataBase =
|
||||
typeof source?.metadataBase !== 'undefined'
|
||||
|
@ -96,7 +103,9 @@ function merge(
|
|||
break
|
||||
}
|
||||
case 'alternates': {
|
||||
target.alternates = resolveAlternates(source.alternates, metadataBase)
|
||||
target.alternates = resolveAlternates(source.alternates, metadataBase, {
|
||||
pathname,
|
||||
})
|
||||
break
|
||||
}
|
||||
case 'openGraph': {
|
||||
|
@ -271,7 +280,8 @@ export async function collectMetadata({
|
|||
}
|
||||
|
||||
export async function accumulateMetadata(
|
||||
metadataItems: MetadataItems
|
||||
metadataItems: MetadataItems,
|
||||
pathname: string
|
||||
): Promise<ResolvedMetadata> {
|
||||
const resolvedMetadata = createDefaultMetadata()
|
||||
|
||||
|
@ -339,7 +349,13 @@ export async function accumulateMetadata(
|
|||
metadata = metadataExport
|
||||
}
|
||||
|
||||
merge(resolvedMetadata, metadata, staticFilesMetadata, titleTemplates)
|
||||
merge({
|
||||
pathname,
|
||||
target: resolvedMetadata,
|
||||
source: metadata,
|
||||
staticFilesMetadata,
|
||||
titleTemplates,
|
||||
})
|
||||
|
||||
// If the layout is the same layer with page, skip the leaf layout and leaf page
|
||||
// The leaf layout and page are the last two items
|
||||
|
|
|
@ -9,10 +9,26 @@ import type {
|
|||
FieldResolverWithMetadataBase,
|
||||
} from '../types/resolvers'
|
||||
import type { Viewport } from '../types/extra-types'
|
||||
import path from '../../../shared/lib/isomorphic/path'
|
||||
import { resolveAsArrayOrUndefined } from '../generate/utils'
|
||||
import { resolveUrl } from './resolve-url'
|
||||
import { resolveUrl, resolveStringUrl } from './resolve-url'
|
||||
import { ViewPortKeys } from '../constants'
|
||||
|
||||
// Resolve with `metadataBase` if it's present, otherwise resolve with `pathname`.
|
||||
// Resolve with `pathname` if `url` is a relative path.
|
||||
function resolveAlternateUrl(
|
||||
url: string | URL,
|
||||
metadataBase: URL | null,
|
||||
pathname: string
|
||||
) {
|
||||
if (typeof url === 'string' && url.startsWith('./')) {
|
||||
url = path.resolve(pathname, url)
|
||||
}
|
||||
|
||||
const result = metadataBase ? resolveUrl(url, metadataBase) : url
|
||||
return resolveStringUrl(result)
|
||||
}
|
||||
|
||||
export const resolveThemeColor: FieldResolver<'themeColor'> = (themeColor) => {
|
||||
if (!themeColor) return null
|
||||
const themeColorDescriptors: ResolvedMetadata['themeColor'] = []
|
||||
|
@ -55,7 +71,8 @@ function resolveUrlValuesOfObject(
|
|||
| Record<string, string | URL | AlternateLinkDescriptor[] | null>
|
||||
| null
|
||||
| undefined,
|
||||
metadataBase: ResolvedMetadata['metadataBase']
|
||||
metadataBase: ResolvedMetadata['metadataBase'],
|
||||
pathname: string
|
||||
): null | Record<string, AlternateLinkDescriptor[]> {
|
||||
if (!obj) return null
|
||||
|
||||
|
@ -64,15 +81,13 @@ function resolveUrlValuesOfObject(
|
|||
if (typeof value === 'string' || value instanceof URL) {
|
||||
result[key] = [
|
||||
{
|
||||
url: metadataBase ? resolveUrl(value, metadataBase)! : value,
|
||||
url: resolveAlternateUrl(value, metadataBase, pathname), // metadataBase ? resolveUrl(value, metadataBase)! : value,
|
||||
},
|
||||
]
|
||||
} else {
|
||||
result[key] = []
|
||||
value?.forEach((item, index) => {
|
||||
const url = metadataBase
|
||||
? resolveUrl(item.url, metadataBase)!
|
||||
: item.url
|
||||
const url = resolveAlternateUrl(item.url, metadataBase, pathname)
|
||||
result[key][index] = {
|
||||
url,
|
||||
title: item.title,
|
||||
|
@ -85,35 +100,48 @@ function resolveUrlValuesOfObject(
|
|||
|
||||
function resolveCanonicalUrl(
|
||||
urlOrDescriptor: string | URL | null | AlternateLinkDescriptor | undefined,
|
||||
metadataBase: URL | null
|
||||
metadataBase: URL | null,
|
||||
pathname: string
|
||||
): null | AlternateLinkDescriptor {
|
||||
if (!urlOrDescriptor) return null
|
||||
|
||||
if (typeof urlOrDescriptor === 'string' || urlOrDescriptor instanceof URL) {
|
||||
return {
|
||||
url: (metadataBase
|
||||
? resolveUrl(urlOrDescriptor, metadataBase)
|
||||
: urlOrDescriptor)!,
|
||||
}
|
||||
} else {
|
||||
const url = metadataBase
|
||||
? resolveUrl(urlOrDescriptor.url, metadataBase)
|
||||
const url =
|
||||
typeof urlOrDescriptor === 'string' || urlOrDescriptor instanceof URL
|
||||
? urlOrDescriptor
|
||||
: urlOrDescriptor.url
|
||||
urlOrDescriptor.url = url!
|
||||
return urlOrDescriptor
|
||||
|
||||
// Return string url because structureClone can't handle URL instance
|
||||
return {
|
||||
url: resolveAlternateUrl(url, metadataBase, pathname),
|
||||
}
|
||||
}
|
||||
|
||||
export const resolveAlternates: FieldResolverWithMetadataBase<'alternates'> = (
|
||||
alternates,
|
||||
metadataBase
|
||||
) => {
|
||||
export const resolveAlternates: FieldResolverWithMetadataBase<
|
||||
'alternates',
|
||||
{ pathname: string }
|
||||
> = (alternates, metadataBase, { pathname }) => {
|
||||
if (!alternates) return null
|
||||
|
||||
const canonical = resolveCanonicalUrl(alternates.canonical, metadataBase)
|
||||
const languages = resolveUrlValuesOfObject(alternates.languages, metadataBase)
|
||||
const media = resolveUrlValuesOfObject(alternates.media, metadataBase)
|
||||
const types = resolveUrlValuesOfObject(alternates.types, metadataBase)
|
||||
const canonical = resolveCanonicalUrl(
|
||||
alternates.canonical,
|
||||
metadataBase,
|
||||
pathname
|
||||
)
|
||||
const languages = resolveUrlValuesOfObject(
|
||||
alternates.languages,
|
||||
metadataBase,
|
||||
pathname
|
||||
)
|
||||
const media = resolveUrlValuesOfObject(
|
||||
alternates.media,
|
||||
metadataBase,
|
||||
pathname
|
||||
)
|
||||
const types = resolveUrlValuesOfObject(
|
||||
alternates.types,
|
||||
metadataBase,
|
||||
pathname
|
||||
)
|
||||
|
||||
const result: ResolvedAlternateURLs = {
|
||||
canonical,
|
||||
|
|
|
@ -5,12 +5,18 @@ function isStringOrURL(icon: any): icon is string | URL {
|
|||
return typeof icon === 'string' || icon instanceof URL
|
||||
}
|
||||
|
||||
function resolveUrl(url: null | undefined, metadataBase: URL | null): null
|
||||
function resolveUrl(url: string | URL, metadataBase: URL | null): URL
|
||||
function resolveUrl(
|
||||
url: string | URL | null | undefined,
|
||||
metadataBase: URL | null
|
||||
): URL | null
|
||||
function resolveUrl(
|
||||
url: string | URL | null | undefined,
|
||||
metadataBase: URL | null
|
||||
): URL | null {
|
||||
if (!url) return null
|
||||
if (url instanceof URL) return url
|
||||
if (!url) return null
|
||||
|
||||
try {
|
||||
// If we can construct a URL instance from url, ignore metadataBase
|
||||
|
@ -39,4 +45,10 @@ function resolveUrl(
|
|||
return new URL(joinedPath, metadataBase)
|
||||
}
|
||||
|
||||
export { isStringOrURL, resolveUrl }
|
||||
// Return a string url without trailing slash
|
||||
const resolveStringUrl = (url: string | URL) => {
|
||||
const href = typeof url === 'string' ? url : url.toString()
|
||||
return href.endsWith('/') ? href.slice(0, -1) : href
|
||||
}
|
||||
|
||||
export { isStringOrURL, resolveUrl, resolveStringUrl }
|
||||
|
|
|
@ -3,7 +3,16 @@ import { Metadata, ResolvedMetadata } from './metadata-interface'
|
|||
export type FieldResolver<Key extends keyof Metadata> = (
|
||||
T: Metadata[Key]
|
||||
) => ResolvedMetadata[Key]
|
||||
export type FieldResolverWithMetadataBase<Key extends keyof Metadata> = (
|
||||
T: Metadata[Key],
|
||||
metadataBase: ResolvedMetadata['metadataBase']
|
||||
) => ResolvedMetadata[Key]
|
||||
export type FieldResolverWithMetadataBase<
|
||||
Key extends keyof Metadata,
|
||||
Options = undefined
|
||||
> = Options extends undefined
|
||||
? (
|
||||
T: Metadata[Key],
|
||||
metadataBase: ResolvedMetadata['metadataBase']
|
||||
) => ResolvedMetadata[Key]
|
||||
: (
|
||||
T: Metadata[Key],
|
||||
metadataBase: ResolvedMetadata['metadataBase'],
|
||||
options: Options
|
||||
) => ResolvedMetadata[Key]
|
||||
|
|
|
@ -1033,7 +1033,11 @@ export async function renderToHTMLOrFlight(
|
|||
<>
|
||||
{/* Adding key={requestId} to make metadata remount for each render */}
|
||||
{/* @ts-expect-error allow to use async server component */}
|
||||
<MetadataTree key={requestId} metadata={metadataItems} />
|
||||
<MetadataTree
|
||||
key={requestId}
|
||||
metadata={metadataItems}
|
||||
pathname={pathname}
|
||||
/>
|
||||
</>
|
||||
),
|
||||
injectedCSS: new Set(),
|
||||
|
@ -1168,7 +1172,11 @@ export async function renderToHTMLOrFlight(
|
|||
<>
|
||||
{/* Adding key={requestId} to make metadata remount for each render */}
|
||||
{/* @ts-expect-error allow to use async server component */}
|
||||
<MetadataTree key={requestId} metadata={metadataItems} />
|
||||
<MetadataTree
|
||||
key={requestId}
|
||||
metadata={metadataItems}
|
||||
pathname={pathname}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
globalErrorComponent={GlobalError}
|
||||
|
@ -1355,7 +1363,11 @@ export async function renderToHTMLOrFlight(
|
|||
<html id="__next_error__">
|
||||
<head>
|
||||
{/* @ts-expect-error allow to use async server component */}
|
||||
<MetadataTree key={requestId} metadata={[]} />
|
||||
<MetadataTree
|
||||
key={requestId}
|
||||
metadata={[]}
|
||||
pathname={pathname}
|
||||
/>
|
||||
</head>
|
||||
<body></body>
|
||||
</html>
|
||||
|
|
|
@ -1,23 +0,0 @@
|
|||
export default function Page() {
|
||||
return 'hello'
|
||||
}
|
||||
|
||||
export const metadata = {
|
||||
metadataBase: null,
|
||||
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)': '/mobile',
|
||||
},
|
||||
types: {
|
||||
'application/rss+xml': [
|
||||
{ url: '/blog.rss', title: 'rss' },
|
||||
{ url: '/blog/js.rss', title: 'js title' },
|
||||
],
|
||||
},
|
||||
},
|
||||
}
|
3
test/e2e/app-dir/metadata/app/alternates/child/page.tsx
Normal file
3
test/e2e/app-dir/metadata/app/alternates/child/page.tsx
Normal file
|
@ -0,0 +1,3 @@
|
|||
export default function Page() {
|
||||
return 'alternate-child'
|
||||
}
|
14
test/e2e/app-dir/metadata/app/alternates/layout.tsx
Normal file
14
test/e2e/app-dir/metadata/app/alternates/layout.tsx
Normal file
|
@ -0,0 +1,14 @@
|
|||
export default function layout({ children }) {
|
||||
return children
|
||||
}
|
||||
|
||||
export const metadata = {
|
||||
metadataBase: 'https://example.com',
|
||||
alternates: {
|
||||
canonical: './',
|
||||
languages: {
|
||||
'en-US': './en-US',
|
||||
'de-DE': './de-DE',
|
||||
},
|
||||
},
|
||||
}
|
23
test/e2e/app-dir/metadata/app/alternates/page.tsx
Normal file
23
test/e2e/app-dir/metadata/app/alternates/page.tsx
Normal file
|
@ -0,0 +1,23 @@
|
|||
export default function Page() {
|
||||
return 'alternate'
|
||||
}
|
||||
|
||||
export async function generateMetadata(props, parentResolvingMetadata) {
|
||||
const parentMetadata = await parentResolvingMetadata
|
||||
|
||||
return {
|
||||
...parentMetadata,
|
||||
alternates: {
|
||||
...parentMetadata.alternates,
|
||||
media: {
|
||||
'only screen and (max-width: 600px)': '/mobile',
|
||||
},
|
||||
types: {
|
||||
'application/rss+xml': [
|
||||
{ url: '/blog.rss', title: 'rss' },
|
||||
{ url: '/blog/js.rss', title: 'js title' },
|
||||
],
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
|
@ -260,41 +260,47 @@ createNextDescribe(
|
|||
})
|
||||
|
||||
it('should support alternate tags', async () => {
|
||||
const browser = await next.browser('/alternate')
|
||||
await checkLink(browser, 'canonical', 'https://example.com')
|
||||
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)',
|
||||
'/mobile',
|
||||
'media',
|
||||
'link',
|
||||
'href'
|
||||
)
|
||||
const browser = await next.browser('/alternates')
|
||||
const matchDom = createDomMatcher(browser)
|
||||
|
||||
await matchDom('link', 'rel="canonical"', {
|
||||
href: 'https://example.com/alternates',
|
||||
})
|
||||
await matchDom('link', 'title="js title"', {
|
||||
type: 'application/rss+xml',
|
||||
href: '/blog/js.rss',
|
||||
href: 'https://example.com/blog/js.rss',
|
||||
})
|
||||
await matchDom('link', 'title="rss"', {
|
||||
type: 'application/rss+xml',
|
||||
href: '/blog.rss',
|
||||
href: 'https://example.com/blog.rss',
|
||||
})
|
||||
await matchDom('link', 'hreflang="en-US"', {
|
||||
rel: 'alternate',
|
||||
href: 'https://example.com/alternates/en-US',
|
||||
})
|
||||
await matchDom('link', 'hreflang="de-DE"', {
|
||||
rel: 'alternate',
|
||||
href: 'https://example.com/alternates/de-DE',
|
||||
})
|
||||
await matchDom('link', 'media="only screen and (max-width: 600px)"', {
|
||||
rel: 'alternate',
|
||||
href: 'https://example.com/mobile',
|
||||
})
|
||||
})
|
||||
|
||||
it('should relative canonical url', async () => {
|
||||
const browser = await next.browser('/alternates/child')
|
||||
const matchDom = createDomMatcher(browser)
|
||||
await matchDom('link', 'rel="canonical"', {
|
||||
href: 'https://example.com/alternates/child',
|
||||
})
|
||||
await matchDom('link', 'hreflang="en-US"', {
|
||||
rel: 'alternate',
|
||||
href: 'https://example.com/alternates/child/en-US',
|
||||
})
|
||||
await matchDom('link', 'hreflang="de-DE"', {
|
||||
rel: 'alternate',
|
||||
href: 'https://example.com/alternates/child/de-DE',
|
||||
})
|
||||
})
|
||||
|
||||
|
|
Loading…
Reference in a new issue