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:
Jiachi Liu 2023-03-31 17:44:39 +02:00 committed by GitHub
parent e1a397d750
commit 04bfb314e0
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 222 additions and 106 deletions

View file

@ -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
)

View file

@ -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)}
/>
)
}

View file

@ -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(',')} />

View file

@ -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} />
}
}

View file

@ -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 (
<>

View file

@ -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 () => {

View file

@ -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

View file

@ -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,

View file

@ -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 }

View file

@ -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]

View file

@ -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>

View file

@ -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' },
],
},
},
}

View file

@ -0,0 +1,3 @@
export default function Page() {
return 'alternate-child'
}

View 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',
},
},
}

View 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' },
],
},
},
}
}

View file

@ -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',
})
})