Skip to content

Commit

Permalink
Support relative path for metadata alternates urls (#47743)
Browse files Browse the repository at this point in the history
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`
  • Loading branch information
huozhi committed Mar 31, 2023
1 parent e1a397d commit 04bfb31
Show file tree
Hide file tree
Showing 16 changed files with 222 additions and 106 deletions.
3 changes: 2 additions & 1 deletion packages/next/src/export/worker.ts
Expand Up @@ -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)
Expand Down Expand Up @@ -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
)
Expand Down
3 changes: 2 additions & 1 deletion packages/next/src/lib/metadata/generate/alternate.tsx
Expand Up @@ -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,
Expand All @@ -14,7 +15,7 @@ function AlternateLink({
<link
{...props}
{...(descriptor.title && { title: descriptor.title })}
href={descriptor.url.toString()}
href={resolveStringUrl(descriptor.url)}
/>
)
}
Expand Down
7 changes: 5 additions & 2 deletions packages/next/src/lib/metadata/generate/basic.tsx
Expand Up @@ -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 (
Expand All @@ -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(',')} />
Expand Down
8 changes: 3 additions & 5 deletions packages/next/src/lib/metadata/generate/icons.tsx
Expand Up @@ -2,22 +2,20 @@ 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 }) {
if (typeof icon === 'object' && !(icon instanceof URL)) {
if (rel) icon.rel = rel
return <IconDescriptorLink icon={icon} />
} else {
const href = resolveUrl(icon)
const href = resolveStringUrl(icon)
return <link rel={rel} href={href} />
}
}
Expand Down
10 changes: 8 additions & 2 deletions packages/next/src/lib/metadata/metadata.tsx
Expand Up @@ -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 (
<>
Expand Down
9 changes: 8 additions & 1 deletion packages/next/src/lib/metadata/resolve-metadata.test.ts
@@ -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 () => {
Expand Down
32 changes: 24 additions & 8 deletions packages/next/src/lib/metadata/resolve-metadata.ts
Expand Up @@ -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'
Expand All @@ -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': {
Expand Down Expand Up @@ -271,7 +280,8 @@ export async function collectMetadata({
}

export async function accumulateMetadata(
metadataItems: MetadataItems
metadataItems: MetadataItems,
pathname: string
): Promise<ResolvedMetadata> {
const resolvedMetadata = createDefaultMetadata()

Expand Down Expand Up @@ -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
Expand Down
80 changes: 54 additions & 26 deletions packages/next/src/lib/metadata/resolvers/resolve-basics.ts
Expand Up @@ -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'] = []
Expand Down Expand Up @@ -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

Expand All @@ -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,
Expand All @@ -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,
Expand Down
16 changes: 14 additions & 2 deletions packages/next/src/lib/metadata/resolvers/resolve-url.ts
Expand Up @@ -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
Expand Down Expand Up @@ -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 }
17 changes: 13 additions & 4 deletions packages/next/src/lib/metadata/types/resolvers.ts
Expand Up @@ -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]
18 changes: 15 additions & 3 deletions packages/next/src/server/app-render/app-render.tsx
Expand Up @@ -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(),
Expand Down Expand Up @@ -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}
Expand Down Expand Up @@ -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>
Expand Down

0 comments on commit 04bfb31

Please sign in to comment.