From 4e54429369803fba0c9e6598065e3434d325d157 Mon Sep 17 00:00:00 2001 From: Jiachi Liu Date: Thu, 2 Mar 2023 23:42:10 +0100 Subject: [PATCH] Support multi title for alternates (#46700) 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) --- .../src/lib/metadata/generate/alternate.tsx | 68 ++++++++------ .../src/lib/metadata/resolve-metadata.test.ts | 88 +++++++++++++++++++ .../lib/metadata/resolvers/resolve-basics.ts | 81 ++++++++++++++--- .../src/lib/metadata/resolvers/resolve-url.ts | 15 +--- .../metadata/types/alternative-urls-types.ts | 21 +++-- .../lib/metadata/types/metadata-interface.ts | 16 ++++ .../app-dir/metadata/app/alternate/page.tsx | 5 +- test/e2e/app-dir/metadata/metadata.test.ts | 24 +++-- 8 files changed, 250 insertions(+), 68 deletions(-) diff --git a/packages/next/src/lib/metadata/generate/alternate.tsx b/packages/next/src/lib/metadata/generate/alternate.tsx index b5280f2929d79..a62fcf870653e 100644 --- a/packages/next/src/lib/metadata/generate/alternate.tsx +++ b/packages/next/src/lib/metadata/generate/alternate.tsx @@ -1,6 +1,23 @@ 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) { + if (!descriptor.url) return null + return ( + + ) +} export function AlternatesMetadata({ alternates, @@ -8,45 +25,46 @@ export function AlternatesMetadata({ alternates: ResolvedMetadata['alternates'] }) { if (!alternates) return null + const { canonical, languages, media, types } = alternates return ( <> - {alternates.canonical ? ( - + {canonical ? ( + ) : null} - {alternates.languages - ? Object.entries(alternates.languages).map(([locale, url]) => - url ? ( - { + return descriptors?.map((descriptor, index) => ( + - ) : null - ) + )) + }) : null} - {alternates.media - ? Object.entries(alternates.media).map(([media, url]) => - url ? ( - + descriptors?.map((descriptor, index) => ( + - ) : null + )) ) : null} - {alternates.types - ? Object.entries(alternates.types).map(([type, url]) => - url ? ( - + descriptors?.map((descriptor, index) => ( + - ) : null + )) ) : null} diff --git a/packages/next/src/lib/metadata/resolve-metadata.test.ts b/packages/next/src/lib/metadata/resolve-metadata.test.ts index d62dfe15ae904..c24856dc36f49 100644 --- a/packages/next/src/lib/metadata/resolve-metadata.test.ts +++ b/packages/next/src/lib/metadata/resolve-metadata.test.ts @@ -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' }], + }, + }, + }) + }) + }) }) diff --git a/packages/next/src/lib/metadata/resolvers/resolve-basics.ts b/packages/next/src/lib/metadata/resolvers/resolve-basics.ts index e49921dabf3ec..801312de4cc49 100644 --- a/packages/next/src/lib/metadata/resolvers/resolve-basics.ts +++ b/packages/next/src/lib/metadata/resolvers/resolve-basics.ts @@ -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 { @@ -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) => { @@ -47,23 +50,77 @@ export const resolveViewport: FieldResolver<'viewport'> = (viewport) => { return resolved } +function resolveUrlValuesOfObject( + obj: + | Record + | null + | undefined, + metadataBase: ResolvedMetadata['metadataBase'] +): null | Record { + if (!obj) return null + + const result: Record = {} + 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 } diff --git a/packages/next/src/lib/metadata/resolvers/resolve-url.ts b/packages/next/src/lib/metadata/resolvers/resolve-url.ts index 2236283a5b09c..6fc89ddfc72fe 100644 --- a/packages/next/src/lib/metadata/resolvers/resolve-url.ts +++ b/packages/next/src/lib/metadata/resolvers/resolve-url.ts @@ -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 @@ -30,16 +29,4 @@ function resolveUrl( return new URL(joinedPath, metadataBase) } -function resolveUrlValuesOfObject( - obj: Record | null | undefined, - metadataBase: ResolvedMetadata['metadataBase'] -): null | Record { - if (!obj) return null - const result: Record = {} - for (const [key, value] of Object.entries(obj)) { - result[key] = metadataBase ? resolveUrl(value, metadataBase) : value - } - return result -} - -export { isStringOrURL, resolveUrl, resolveUrlValuesOfObject } +export { isStringOrURL, resolveUrl } diff --git a/packages/next/src/lib/metadata/types/alternative-urls-types.ts b/packages/next/src/lib/metadata/types/alternative-urls-types.ts index 1df4cf92ad49b..fd54da9c87a06 100644 --- a/packages/next/src/lib/metadata/types/alternative-urls-types.ts +++ b/packages/next/src/lib/metadata/types/alternative-urls-types.ts @@ -422,24 +422,29 @@ type Languages = { [s in HrefLang]?: T } +export type AlternateLinkDescriptor = { + title?: string + url: string | URL +} + export type AlternateURLs = { - canonical?: null | string | URL - languages?: Languages + canonical?: null | string | URL | AlternateLinkDescriptor + languages?: Languages 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 + canonical: null | AlternateLinkDescriptor + languages: null | Languages media: null | { - [media: string]: null | string | URL + [media: string]: null | AlternateLinkDescriptor[] } types: null | { - [types: string]: null | string | URL + [types: string]: null | AlternateLinkDescriptor[] } } diff --git a/packages/next/src/lib/metadata/types/metadata-interface.ts b/packages/next/src/lib/metadata/types/metadata-interface.ts index a602597f6a7a9..d62462dfcb324 100644 --- a/packages/next/src/lib/metadata/types/metadata-interface.ts +++ b/packages/next/src/lib/metadata/types/metadata-interface.ts @@ -218,6 +218,22 @@ interface Metadata extends DeprecatedMetadataFields { * * * ``` + * + * 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' }, + * ], + * }, + * } + * + * + * + * ``` */ alternates?: null | AlternateURLs diff --git a/test/e2e/app-dir/metadata/app/alternate/page.tsx b/test/e2e/app-dir/metadata/app/alternate/page.tsx index d7a6d1f179470..2ef6cc58eb651 100644 --- a/test/e2e/app-dir/metadata/app/alternate/page.tsx +++ b/test/e2e/app-dir/metadata/app/alternate/page.tsx @@ -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' }, + ], }, }, } diff --git a/test/e2e/app-dir/metadata/metadata.test.ts b/test/e2e/app-dir/metadata/metadata.test.ts index 9eb791fbd550b..138aab893fb78 100644 --- a/test/e2e/app-dir/metadata/metadata.test.ts +++ b/test/e2e/app-dir/metadata/metadata.test.ts @@ -233,14 +233,22 @@ createNextDescribe( 'link', 'href' ) - await checkMeta( - browser, - 'application/rss+xml', - 'https://example.com/rss', - 'type', - 'link', - 'href' - ) + expect( + await queryMetaProps(browser, 'link', 'title="js title"', [ + 'type', + 'href', + ]) + ).toEqual({ + type: 'application/rss+xml', + href: 'blog/js.rss', + }) + + expect( + await queryMetaProps(browser, 'link', 'title="rss"', ['type', 'href']) + ).toEqual({ + type: 'application/rss+xml', + href: 'blog.rss', + }) }) it('should support robots tags', async () => {