Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support multi title for alternates #46700

Merged
merged 5 commits into from
Mar 2, 2023
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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' },
],
},
},
}