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:
Jiachi Liu 2023-01-24 19:19:11 +01:00 committed by GitHub
parent ff1664b11b
commit 45a9373113
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 295 additions and 54 deletions

View file

@ -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,

View 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}
</>
)
}

View file

@ -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,

View 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]
}

View file

@ -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} />
</>
)
}

View file

@ -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: {

View file

@ -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':

View file

@ -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

View file

@ -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[]
}

View file

@ -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'

View 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',
},
],
},
}

View 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',
},
},
}

View file

@ -0,0 +1,7 @@
export default function page() {
return 'icons'
}
export const metadata = {
icons: '/icon.png',
}

View file

@ -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' })
})
})
})
}
)