Support metadata icons field (#45105)
NEXT-400 ## Feature - [x] Implements an existing feature request or RFC. Make sure the feature request has been accepted for implementation before opening a PR. - [ ] 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)
This commit is contained in:
parent
ff1664b11b
commit
45a9373113
15 changed files with 295 additions and 54 deletions
|
@ -1,7 +1,7 @@
|
|||
import type { ResolvedMetadata } from '../types/metadata-interface'
|
||||
|
||||
import React from 'react'
|
||||
import { Meta } from './utils'
|
||||
import { Meta } from './meta'
|
||||
|
||||
export function ResolvedBasicMetadata({
|
||||
metadata,
|
||||
|
|
69
packages/next/src/lib/metadata/generate/icons.tsx
Normal file
69
packages/next/src/lib/metadata/generate/icons.tsx
Normal file
|
@ -0,0 +1,69 @@
|
|||
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()
|
||||
|
||||
function IconDescriptorLink({ icon }: { icon: IconDescriptor }) {
|
||||
const { url, rel = 'icon', ...props } = icon
|
||||
|
||||
return <link rel={rel} href={resolveUrl(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)
|
||||
return <link rel={rel} href={href} />
|
||||
}
|
||||
}
|
||||
|
||||
export function ResolvedIconsMetadata({
|
||||
icons,
|
||||
}: {
|
||||
icons: ResolvedMetadata['icons']
|
||||
}) {
|
||||
if (!icons) return null
|
||||
|
||||
const shortcutList = icons.shortcut
|
||||
const iconList = icons.icon
|
||||
const appleList = icons.apple
|
||||
const otherList = icons.other
|
||||
|
||||
return (
|
||||
<>
|
||||
{shortcutList
|
||||
? shortcutList.map((icon, index) => (
|
||||
<IconLink
|
||||
key={`shortcut-${index}`}
|
||||
rel="shortcut icon"
|
||||
icon={icon}
|
||||
/>
|
||||
))
|
||||
: null}
|
||||
{iconList
|
||||
? iconList.map((icon, index) => (
|
||||
<IconLink key={`shortcut-${index}`} rel="icon" icon={icon} />
|
||||
))
|
||||
: null}
|
||||
{appleList
|
||||
? appleList.map((icon, index) => (
|
||||
<IconLink
|
||||
key={`apple-${index}`}
|
||||
rel="apple-touch-icon"
|
||||
icon={icon}
|
||||
/>
|
||||
))
|
||||
: null}
|
||||
{otherList
|
||||
? otherList.map((icon, index) => (
|
||||
<IconDescriptorLink key={`other-${index}`} icon={icon} />
|
||||
))
|
||||
: null}
|
||||
</>
|
||||
)
|
||||
}
|
|
@ -1,7 +1,7 @@
|
|||
import type { ResolvedMetadata } from '../types/metadata-interface'
|
||||
|
||||
import React from 'react'
|
||||
import { Meta, MultiMeta } from './utils'
|
||||
import { Meta, MultiMeta } from './meta'
|
||||
|
||||
export function ResolvedOpenGraphMetadata({
|
||||
openGraph,
|
||||
|
|
11
packages/next/src/lib/metadata/generate/utils.ts
Normal file
11
packages/next/src/lib/metadata/generate/utils.ts
Normal file
|
@ -0,0 +1,11 @@
|
|||
export function resolveAsArrayOrUndefined<T = any>(
|
||||
value: T | T[] | undefined | null
|
||||
): undefined | T[] {
|
||||
if (typeof value === 'undefined' || value === null) {
|
||||
return undefined
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value
|
||||
}
|
||||
return [value]
|
||||
}
|
|
@ -1,19 +1,23 @@
|
|||
import React from 'react'
|
||||
|
||||
import type { ResolvedMetadata } from './types/metadata-interface'
|
||||
|
||||
import React from 'react'
|
||||
import { ResolvedBasicMetadata } from './generate/basic'
|
||||
import { ResolvedAlternatesMetadata } from './generate/alternate'
|
||||
import { ResolvedOpenGraphMetadata } from './generate/opengraph'
|
||||
import { resolveMetadata } from './resolve-metadata'
|
||||
import { ResolvedIconsMetadata } from './generate/icons'
|
||||
|
||||
// Generate the actual React elements from the resolved metadata.
|
||||
export async function Metadata({ metadata }: { metadata: any }) {
|
||||
if (!metadata) return null
|
||||
|
||||
const resolved: ResolvedMetadata = await resolveMetadata(metadata)
|
||||
return (
|
||||
<>
|
||||
<ResolvedBasicMetadata metadata={resolved} />
|
||||
<ResolvedAlternatesMetadata metadata={resolved} />
|
||||
<ResolvedOpenGraphMetadata openGraph={resolved.openGraph} />
|
||||
<ResolvedIconsMetadata icons={resolved.icons} />
|
||||
</>
|
||||
)
|
||||
}
|
|
@ -5,10 +5,16 @@ import type {
|
|||
} from './types/metadata-interface'
|
||||
import type { Viewport } from './types/extra-types'
|
||||
import type { ResolvedTwitterMetadata } from './types/twitter-types'
|
||||
import type { AbsoluteTemplateString } from './types/metadata-types'
|
||||
import type {
|
||||
AbsoluteTemplateString,
|
||||
Icon,
|
||||
IconDescriptor,
|
||||
Icons,
|
||||
} from './types/metadata-types'
|
||||
import { createDefaultMetadata } from './default-metadata'
|
||||
import { resolveOpenGraph } from './resolve-opengraph'
|
||||
import { mergeTitle } from './resolve-title'
|
||||
import { resolveAsArrayOrUndefined } from './generate/utils'
|
||||
|
||||
const viewPortKeys = {
|
||||
width: 'width',
|
||||
|
@ -48,6 +54,57 @@ type Item =
|
|||
path?: string
|
||||
}
|
||||
|
||||
function resolveViewport(
|
||||
viewport: Metadata['viewport']
|
||||
): ResolvedMetadata['viewport'] {
|
||||
let resolved: ResolvedMetadata['viewport'] = null
|
||||
|
||||
if (typeof viewport === 'string') {
|
||||
resolved = viewport
|
||||
} else if (viewport) {
|
||||
resolved = ''
|
||||
for (const viewportKey_ in viewPortKeys) {
|
||||
const viewportKey = viewportKey_ as keyof Viewport
|
||||
if (viewport[viewportKey]) {
|
||||
if (resolved) resolved += ', '
|
||||
resolved += `${viewPortKeys[viewportKey]}=${viewport[viewportKey]}`
|
||||
}
|
||||
}
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
|
||||
function isUrlIcon(icon: any): icon is string | URL {
|
||||
return typeof icon === 'string' || icon instanceof URL
|
||||
}
|
||||
|
||||
function resolveIcon(icon: Icon): IconDescriptor {
|
||||
if (isUrlIcon(icon)) return { url: icon }
|
||||
else if (Array.isArray(icon)) return icon
|
||||
return icon
|
||||
}
|
||||
|
||||
const IconKeys = ['icon', 'shortcut', 'apple', 'other'] as (keyof Icons)[]
|
||||
|
||||
function resolveIcons(icons: Metadata['icons']): ResolvedMetadata['icons'] {
|
||||
if (!icons) {
|
||||
return null
|
||||
}
|
||||
|
||||
const resolved: ResolvedMetadata['icons'] = {}
|
||||
if (Array.isArray(icons)) {
|
||||
resolved.icon = icons.map(resolveIcon).filter(Boolean)
|
||||
} else if (isUrlIcon(icons)) {
|
||||
resolved.icon = [resolveIcon(icons)]
|
||||
} else {
|
||||
for (const key of IconKeys) {
|
||||
const values = resolveAsArrayOrUndefined(icons[key])
|
||||
if (values) resolved[key] = values.map(resolveIcon)
|
||||
}
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
|
||||
// Merge the source metadata into the resolved target metadata.
|
||||
function merge(
|
||||
target: ResolvedMetadata,
|
||||
|
@ -94,21 +151,11 @@ function merge(
|
|||
break
|
||||
}
|
||||
case 'viewport': {
|
||||
let content: string | null = null
|
||||
const { viewport } = source
|
||||
if (typeof viewport === 'string') {
|
||||
content = viewport
|
||||
} else if (viewport) {
|
||||
content = ''
|
||||
for (const viewportKey_ in viewPortKeys) {
|
||||
const viewportKey = viewportKey_ as keyof Viewport
|
||||
if (viewport[viewportKey]) {
|
||||
if (content) content += ', '
|
||||
content += `${viewPortKeys[viewportKey]}=${viewport[viewportKey]}`
|
||||
}
|
||||
}
|
||||
}
|
||||
target.viewport = content
|
||||
target.viewport = resolveViewport(source.viewport)
|
||||
break
|
||||
}
|
||||
case 'icons': {
|
||||
target.icons = resolveIcons(source.icons)
|
||||
break
|
||||
}
|
||||
default: {
|
||||
|
|
|
@ -4,6 +4,7 @@ import type {
|
|||
OpenGraph,
|
||||
ResolvedOpenGraph,
|
||||
} from './types/opengraph-types'
|
||||
import { resolveAsArrayOrUndefined } from './generate/utils'
|
||||
|
||||
const OgTypFields = {
|
||||
article: ['authors', 'tags'],
|
||||
|
@ -22,16 +23,6 @@ const OgTypFields = {
|
|||
],
|
||||
} as const
|
||||
|
||||
function resolveAsArrayOrUndefined<T = any>(value: T): undefined | any[] {
|
||||
if (typeof value === 'undefined' || value === null) {
|
||||
return undefined
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value
|
||||
}
|
||||
return [value]
|
||||
}
|
||||
|
||||
function getFieldsByOgType(ogType: OpenGraphType | undefined) {
|
||||
switch (ogType) {
|
||||
case 'article':
|
||||
|
|
|
@ -15,7 +15,9 @@ import type {
|
|||
ColorSchemeEnum,
|
||||
Icon,
|
||||
Icons,
|
||||
IconURL,
|
||||
ReferrerEnum,
|
||||
ResolvedIcons,
|
||||
Robots,
|
||||
TemplateString,
|
||||
Verification,
|
||||
|
@ -56,7 +58,7 @@ export interface Metadata {
|
|||
|
||||
// Defaults to rel="icon" but the Icons type can be used
|
||||
// to get more specific about rel types
|
||||
icons?: null | Array<Icon> | Icons
|
||||
icons?: null | IconURL | Array<Icon> | Icons
|
||||
|
||||
openGraph?: null | OpenGraph
|
||||
|
||||
|
@ -145,7 +147,7 @@ export interface ResolvedMetadata {
|
|||
|
||||
// Defaults to rel="icon" but the Icons type can be used
|
||||
// to get more specific about rel types
|
||||
icons: null | Icons
|
||||
icons: null | ResolvedIcons
|
||||
|
||||
openGraph: null | ResolvedOpenGraph
|
||||
|
||||
|
|
|
@ -64,7 +64,8 @@ export type Robots = {
|
|||
googleBot?: string | Robots
|
||||
}
|
||||
|
||||
export type Icon = string | IconDescriptor | URL
|
||||
export type IconURL = string | URL
|
||||
export type Icon = IconURL | IconDescriptor
|
||||
export type IconDescriptor = {
|
||||
url: string | URL
|
||||
type?: string
|
||||
|
@ -74,20 +75,27 @@ export type IconDescriptor = {
|
|||
}
|
||||
export type Icons = {
|
||||
// rel="icon"
|
||||
icon?: Icon | Array<Icon>
|
||||
icon?: Icon | Icon[]
|
||||
// rel="shortcut icon"
|
||||
shortcut?: Icon | Array<Icon>
|
||||
shortcut?: Icon | Icon[]
|
||||
// rel="apple-touch-icon"
|
||||
apple?: Icon | Array<Icon>
|
||||
apple?: Icon | Icon[]
|
||||
// rel inferred from descriptor, defaults to "icon"
|
||||
other?: Icon | Array<Icon>
|
||||
other?: IconDescriptor | IconDescriptor[]
|
||||
}
|
||||
|
||||
export type Verification = {
|
||||
google?: null | string | number | Array<string | number>
|
||||
yahoo?: null | string | number | Array<string | number>
|
||||
google?: null | string | number | (string | number)[]
|
||||
yahoo?: null | string | number | (string | number)[]
|
||||
// if you ad-hoc additional verification
|
||||
other?: {
|
||||
[name: string]: string | number | Array<string | number>
|
||||
[name: string]: string | number | (string | number)[]
|
||||
}
|
||||
}
|
||||
|
||||
export type ResolvedIcons = {
|
||||
icon?: IconDescriptor[]
|
||||
shortcut?: IconDescriptor[]
|
||||
apple?: IconDescriptor[]
|
||||
other?: IconDescriptor[]
|
||||
}
|
||||
|
|
|
@ -46,7 +46,7 @@ import {
|
|||
} from '../client/components/app-router-headers'
|
||||
import type { StaticGenerationAsyncStorage } from '../client/components/static-generation-async-storage'
|
||||
import { formatServerError } from '../lib/format-server-error'
|
||||
import { Metadata } from '../lib/metadata/ui'
|
||||
import { Metadata } from '../lib/metadata/metadata'
|
||||
import type { RequestAsyncStorage } from '../client/components/request-async-storage'
|
||||
import { runWithRequestAsyncStorage } from './run-with-request-async-storage'
|
||||
import { runWithStaticGenerationAsyncStorage } from './run-with-static-generation-async-storage'
|
||||
|
|
20
test/e2e/app-dir/metadata/app/icons/descriptor/page.js
Normal file
20
test/e2e/app-dir/metadata/app/icons/descriptor/page.js
Normal file
|
@ -0,0 +1,20 @@
|
|||
export default function page() {
|
||||
return 'icons'
|
||||
}
|
||||
|
||||
export const metadata = {
|
||||
icons: {
|
||||
icon: [{ url: '/icon.png' }, new URL('/icon.png', 'https://example.com')],
|
||||
shortcut: ['/shortcut-icon.png'],
|
||||
apple: [
|
||||
{ url: '/apple-icon.png' },
|
||||
{ url: '/apple-icon-x3.png', sizes: '180x180', type: 'image/png' },
|
||||
],
|
||||
other: [
|
||||
{
|
||||
rel: 'apple-touch-icon-precomposed',
|
||||
url: '/apple-touch-icon-precomposed.png',
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
15
test/e2e/app-dir/metadata/app/icons/page.js
Normal file
15
test/e2e/app-dir/metadata/app/icons/page.js
Normal file
|
@ -0,0 +1,15 @@
|
|||
export default function page() {
|
||||
return 'icons'
|
||||
}
|
||||
|
||||
export const metadata = {
|
||||
icons: {
|
||||
icon: '/icon.png',
|
||||
shortcut: '/shortcut-icon.png',
|
||||
apple: '/apple-icon.png',
|
||||
other: {
|
||||
rel: 'apple-touch-icon-precomposed',
|
||||
url: '/apple-touch-icon-precomposed.png',
|
||||
},
|
||||
},
|
||||
}
|
7
test/e2e/app-dir/metadata/app/icons/string/page.js
Normal file
7
test/e2e/app-dir/metadata/app/icons/string/page.js
Normal file
|
@ -0,0 +1,7 @@
|
|||
export default function page() {
|
||||
return 'icons'
|
||||
}
|
||||
|
||||
export const metadata = {
|
||||
icons: '/icon.png',
|
||||
}
|
|
@ -1,4 +1,5 @@
|
|||
import { createNextDescribe } from 'e2e-utils'
|
||||
import { BrowserInterface } from 'test/lib/browsers/base'
|
||||
|
||||
createNextDescribe(
|
||||
'app dir - metadata',
|
||||
|
@ -11,21 +12,39 @@ createNextDescribe(
|
|||
it('should skip for deploy currently', () => {})
|
||||
return
|
||||
}
|
||||
|
||||
const getTitle = (browser: BrowserInterface) =>
|
||||
browser.elementByCss('title').text()
|
||||
|
||||
async function queryMetaProps(
|
||||
browser: BrowserInterface,
|
||||
tag: string,
|
||||
query: string,
|
||||
selectedKeys: string[]
|
||||
) {
|
||||
return await browser.eval(`
|
||||
const res = {}
|
||||
const el = document.querySelector('${tag}[${query}]')
|
||||
for (const k of ${JSON.stringify(selectedKeys)}) {
|
||||
res[k] = el?.getAttribute(k)
|
||||
}
|
||||
res`)
|
||||
}
|
||||
|
||||
async function checkMeta(
|
||||
browser,
|
||||
name,
|
||||
content,
|
||||
property = 'property',
|
||||
tag = 'meta',
|
||||
field = 'content'
|
||||
browser: BrowserInterface,
|
||||
name: string,
|
||||
content: string | string[],
|
||||
property: string = 'property',
|
||||
tag: string = 'meta',
|
||||
field: string = 'content'
|
||||
) {
|
||||
const values = await browser.eval(
|
||||
`[...document.querySelectorAll('${tag}[${property}="${name}"]')].map((el) => el.${field})`
|
||||
`[...document.querySelectorAll('${tag}[${property}="${name}"]')].map((el) => el.getAttribute("${field}"))`
|
||||
)
|
||||
if (Array.isArray(content)) {
|
||||
expect(values).toEqual(content)
|
||||
} else {
|
||||
console.log('expect', values[0], 'toContain', content)
|
||||
expect(values[0]).toContain(content)
|
||||
}
|
||||
}
|
||||
|
@ -150,9 +169,7 @@ createNextDescribe(
|
|||
it('should apply metadata when navigating client-side', async () => {
|
||||
const browser = await next.browser('/')
|
||||
|
||||
const getTitle = () => browser.elementByCss('title').text()
|
||||
|
||||
expect(await getTitle()).toBe('index page')
|
||||
expect(await getTitle(browser)).toBe('index page')
|
||||
await browser
|
||||
.elementByCss('#to-basic')
|
||||
.click()
|
||||
|
@ -165,12 +182,12 @@ createNextDescribe(
|
|||
'name'
|
||||
)
|
||||
await browser.back().waitForElementByCss('#index', 2000)
|
||||
expect(await getTitle()).toBe('index page')
|
||||
expect(await getTitle(browser)).toBe('index page')
|
||||
await browser
|
||||
.elementByCss('#to-title')
|
||||
.click()
|
||||
.waitForElementByCss('#title', 2000)
|
||||
expect(await getTitle()).toBe('this is the page title')
|
||||
expect(await getTitle(browser)).toBe('this is the page title')
|
||||
})
|
||||
})
|
||||
|
||||
|
@ -209,6 +226,56 @@ createNextDescribe(
|
|||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('icons', () => {
|
||||
const checkLink = (browser, name, content) =>
|
||||
checkMeta(browser, name, content, 'rel', 'link', 'href')
|
||||
|
||||
it('should support basic object icons field', async () => {
|
||||
const browser = await next.browser('/icons')
|
||||
|
||||
await checkLink(browser, 'shortcut icon', '/shortcut-icon.png')
|
||||
await checkLink(browser, 'icon', '/icon.png')
|
||||
await checkLink(browser, 'apple-touch-icon', '/apple-icon.png')
|
||||
await checkLink(
|
||||
browser,
|
||||
'apple-touch-icon-precomposed',
|
||||
'/apple-touch-icon-precomposed.png'
|
||||
)
|
||||
})
|
||||
|
||||
it('should support basic string icons field', async () => {
|
||||
const browser = await next.browser('/icons/string')
|
||||
await checkLink(browser, 'icon', '/icon.png')
|
||||
})
|
||||
|
||||
it('should support basic complex descriptor icons field', async () => {
|
||||
const browser = await next.browser('/icons/descriptor')
|
||||
|
||||
await checkLink(browser, 'shortcut icon', '/shortcut-icon.png')
|
||||
await checkLink(browser, 'icon', [
|
||||
'/icon.png',
|
||||
'https://example.com/icon.png',
|
||||
])
|
||||
await checkLink(browser, 'apple-touch-icon', [
|
||||
'/apple-icon.png',
|
||||
'/apple-icon-x3.png',
|
||||
])
|
||||
|
||||
await checkLink(
|
||||
browser,
|
||||
'apple-touch-icon-precomposed',
|
||||
'/apple-touch-icon-precomposed.png'
|
||||
)
|
||||
|
||||
expect(
|
||||
await queryMetaProps(browser, 'link', 'href="/apple-icon-x3.png"', [
|
||||
'sizes',
|
||||
'type',
|
||||
])
|
||||
).toEqual({ sizes: '180x180', type: 'image/png' })
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
)
|
||||
|
|
Loading…
Reference in a new issue