Skip to content

Commit

Permalink
Support multi title for alternates (#46700)
Browse files Browse the repository at this point in the history
This allows to have different titles for alternates urls, but for canonical, you can only have one.

Closes NEXT-624

## Feature

- [x] Implements an existing feature request or RFC. Make sure the feature request has been accepted for implementation before opening a PR.
- [x] Related issues linked using `fixes #number`
- [x] [e2e](https://github.com/vercel/next.js/blob/canary/contributing/core/testing.md#writing-tests-for-nextjs) tests added
- [ ] Documentation added
- [ ] Telemetry added. In case of a feature if it's used or not.
- [ ] Errors have a helpful link attached, see [`contributing.md`](https://github.com/vercel/next.js/blob/canary/contributing.md)

## Documentation / Examples

- [ ] Make sure the linting passes by running `pnpm build && pnpm lint`
- [ ] The "examples guidelines" are followed from [our contributing doc](https://github.com/vercel/next.js/blob/canary/contributing/examples/adding-examples.md)
  • Loading branch information
huozhi committed Mar 2, 2023
1 parent 2a737e7 commit 4e54429
Show file tree
Hide file tree
Showing 8 changed files with 250 additions and 68 deletions.
68 changes: 43 additions & 25 deletions packages/next/src/lib/metadata/generate/alternate.tsx
Original file line number Diff line number Diff line change
@@ -1,52 +1,70 @@
import type { ResolvedMetadata } from '../types/metadata-interface'

import React from 'react'
import { AlternateLinkDescriptor } from '../types/alternative-urls-types'

function AlternateLink({
descriptor,
...props
}: {
descriptor: AlternateLinkDescriptor
} & React.LinkHTMLAttributes<HTMLLinkElement>) {
if (!descriptor.url) return null
return (
<link
{...props}
{...(descriptor.title && { title: descriptor.title })}
href={descriptor.url.toString()}
/>
)
}

export function AlternatesMetadata({
alternates,
}: {
alternates: ResolvedMetadata['alternates']
}) {
if (!alternates) return null
const { canonical, languages, media, types } = alternates
return (
<>
{alternates.canonical ? (
<link rel="canonical" href={alternates.canonical.toString()} />
{canonical ? (
<AlternateLink rel="canonical" descriptor={canonical} />
) : null}
{alternates.languages
? Object.entries(alternates.languages).map(([locale, url]) =>
url ? (
<link
key={locale}
{languages
? Object.entries(languages).map(([locale, descriptors]) => {
return descriptors?.map((descriptor, index) => (
<AlternateLink
rel="alternate"
key={index}
hrefLang={locale}
href={url.toString()}
descriptor={descriptor}
/>
) : null
)
))
})
: null}
{alternates.media
? Object.entries(alternates.media).map(([media, url]) =>
url ? (
<link
key={media}
{media
? Object.entries(media).map(([mediaName, descriptors]) =>
descriptors?.map((descriptor, index) => (
<AlternateLink
rel="alternate"
media={media}
href={url.toString()}
key={index}
media={mediaName}
descriptor={descriptor}
/>
) : null
))
)
: null}
{alternates.types
? Object.entries(alternates.types).map(([type, url]) =>
url ? (
<link
key={type}
{types
? Object.entries(types).map(([type, descriptors]) =>
descriptors?.map((descriptor, index) => (
<AlternateLink
rel="alternate"
key={index}
type={type}
href={url.toString()}
descriptor={descriptor}
/>
) : null
))
)
: null}
</>
Expand Down
88 changes: 88 additions & 0 deletions packages/next/src/lib/metadata/resolve-metadata.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -209,4 +209,92 @@ describe('accumulateMetadata', () => {
})
})
})

describe('alternate', () => {
it('should support string alternate', async () => {
const metadataItems: MetadataItems = [
[
{
alternates: {
canonical: '/relative',
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': 'https://example.com/rss',
},
},
},
null,
],
]
const metadata = await accumulateMetadata(metadataItems)
expect(metadata).toMatchObject({
alternates: {
canonical: { url: '/relative' },
languages: {
'en-US': [{ url: 'https://example.com/en-US' }],
'de-DE': [{ url: 'https://example.com/de-DE' }],
},
media: {
'only screen and (max-width: 600px)': [{ url: '/mobile' }],
},
types: {
'application/rss+xml': [{ url: 'https://example.com/rss' }],
},
},
})
})

it('should support alternate descriptors', async () => {
const metadataItems: MetadataItems = [
[
{
alternates: {
canonical: '/relative',
languages: {
'en-US': [
{ url: '/en-US', title: 'en' },
{ url: '/zh_CN', title: 'zh' },
],
},
media: {
'only screen and (max-width: 600px)': [
{ url: '/mobile', title: 'mobile' },
],
},
types: {
'application/rss+xml': 'https://example.com/rss',
},
},
},
null,
],
]
const metadata = await accumulateMetadata(metadataItems)
expect(metadata).toMatchObject({
alternates: {
canonical: { url: '/relative' },
languages: {
'en-US': [
{ url: '/en-US', title: 'en' },
{ url: '/zh_CN', title: 'zh' },
],
},
media: {
'only screen and (max-width: 600px)': [
{ url: '/mobile', title: 'mobile' },
],
},
types: {
'application/rss+xml': [{ url: 'https://example.com/rss' }],
},
},
})
})
})
})
81 changes: 69 additions & 12 deletions packages/next/src/lib/metadata/resolvers/resolve-basics.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import type { ResolvedAlternateURLs } from '../types/alternative-urls-types'
import type {
AlternateLinkDescriptor,
ResolvedAlternateURLs,
} from '../types/alternative-urls-types'
import type { Metadata, ResolvedMetadata } from '../types/metadata-interface'
import type { ResolvedVerification } from '../types/metadata-types'
import type {
Expand All @@ -7,7 +10,7 @@ import type {
} from '../types/resolvers'
import type { Viewport } from '../types/extra-types'
import { resolveAsArrayOrUndefined } from '../generate/utils'
import { resolveUrl, resolveUrlValuesOfObject } from './resolve-url'
import { resolveUrl } from './resolve-url'
import { ViewPortKeys } from '../constants'

export const resolveThemeColor: FieldResolver<'themeColor'> = (themeColor) => {
Expand Down Expand Up @@ -47,23 +50,77 @@ export const resolveViewport: FieldResolver<'viewport'> = (viewport) => {
return resolved
}

function resolveUrlValuesOfObject(
obj:
| Record<string, string | URL | AlternateLinkDescriptor[] | null>
| null
| undefined,
metadataBase: ResolvedMetadata['metadataBase']
): null | Record<string, AlternateLinkDescriptor[]> {
if (!obj) return null

const result: Record<string, AlternateLinkDescriptor[]> = {}
for (const [key, value] of Object.entries(obj)) {
if (typeof value === 'string' || value instanceof URL) {
result[key] = [
{
url: metadataBase ? resolveUrl(value, metadataBase)! : value,
},
]
} else {
result[key] = []
value?.forEach((item, index) => {
const url = metadataBase
? resolveUrl(item.url, metadataBase)!
: item.url
result[key][index] = {
url,
title: item.title,
}
})
}
}
return result
}

function resolveCanonicalUrl(
urlOrDescriptor: string | URL | null | AlternateLinkDescriptor | undefined,
metadataBase: URL | null
): 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)
: urlOrDescriptor.url
urlOrDescriptor.url = url!
return urlOrDescriptor
}
}

export const resolveAlternates: FieldResolverWithMetadataBase<'alternates'> = (
alternates,
metadataBase
) => {
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 result: ResolvedAlternateURLs = {
canonical: metadataBase
? resolveUrl(alternates.canonical, metadataBase)
: alternates.canonical || null,
languages: null,
media: null,
types: null,
canonical,
languages,
media,
types,
}
const { languages, media, types } = alternates
result.languages = resolveUrlValuesOfObject(languages, metadataBase)
result.media = resolveUrlValuesOfObject(media, metadataBase)
result.types = resolveUrlValuesOfObject(types, metadataBase)

return result
}
Expand Down
15 changes: 1 addition & 14 deletions packages/next/src/lib/metadata/resolvers/resolve-url.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import path from '../../../shared/lib/isomorphic/path'
import type { ResolvedMetadata } from '../types/metadata-interface'

function isStringOrURL(icon: any): icon is string | URL {
return typeof icon === 'string' || icon instanceof URL
Expand Down Expand Up @@ -30,16 +29,4 @@ function resolveUrl(
return new URL(joinedPath, metadataBase)
}

function resolveUrlValuesOfObject(
obj: Record<string, string | URL | null> | null | undefined,
metadataBase: ResolvedMetadata['metadataBase']
): null | Record<string, string | URL | null> {
if (!obj) return null
const result: Record<string, URL | string | null> = {}
for (const [key, value] of Object.entries(obj)) {
result[key] = metadataBase ? resolveUrl(value, metadataBase) : value
}
return result
}

export { isStringOrURL, resolveUrl, resolveUrlValuesOfObject }
export { isStringOrURL, resolveUrl }
21 changes: 13 additions & 8 deletions packages/next/src/lib/metadata/types/alternative-urls-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -422,24 +422,29 @@ type Languages<T> = {
[s in HrefLang]?: T
}

export type AlternateLinkDescriptor = {
title?: string
url: string | URL
}

export type AlternateURLs = {
canonical?: null | string | URL
languages?: Languages<null | string | URL>
canonical?: null | string | URL | AlternateLinkDescriptor
languages?: Languages<null | string | URL | AlternateLinkDescriptor[]>
media?: {
[media: string]: null | string | URL
[media: string]: null | string | URL | AlternateLinkDescriptor[]
}
types?: {
[types: string]: null | string | URL
[types: string]: null | string | URL | AlternateLinkDescriptor[]
}
}

export type ResolvedAlternateURLs = {
canonical: null | string | URL
languages: null | Languages<null | string | URL>
canonical: null | AlternateLinkDescriptor
languages: null | Languages<AlternateLinkDescriptor[]>
media: null | {
[media: string]: null | string | URL
[media: string]: null | AlternateLinkDescriptor[]
}
types: null | {
[types: string]: null | string | URL
[types: string]: null | AlternateLinkDescriptor[]
}
}
16 changes: 16 additions & 0 deletions packages/next/src/lib/metadata/types/metadata-interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,22 @@ interface Metadata extends DeprecatedMetadataFields {
* <link rel="canonical" href="https://example.com" />
* <link rel="alternate" href="https://example.com/en-US" hreflang="en-US" />
* ```
*
* Multiple titles example for alternate URLs except `canonical`:
* ```tsx
* {
* canonical: "https://example.com",
* types: {
* 'application/rss+xml': [
* { url: 'blog.rss', title: 'rss' },
* { url: 'blog/js.rss', title: 'js title' },
* ],
* },
* }
* <link rel="canonical" href="https://example.com" />
* <link rel="alternate" href="https://example.com/blog.rss" type="application/rss+xml" title="rss" />
* <link rel="alternate" href="https://example.com/blog/js.rss" type="application/rss+xml" title="js title" />
* ```
*/
alternates?: null | AlternateURLs

Expand Down
5 changes: 4 additions & 1 deletion test/e2e/app-dir/metadata/app/alternate/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,10 @@ export const metadata = {
'only screen and (max-width: 600px)': '/mobile',
},
types: {
'application/rss+xml': 'https://example.com/rss',
'application/rss+xml': [
{ url: 'blog.rss', title: 'rss' },
{ url: 'blog/js.rss', title: 'js title' },
],
},
},
}

0 comments on commit 4e54429

Please sign in to comment.