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 manifest.json static and dynamic route #47240

Merged
merged 8 commits into from Mar 17, 2023
Merged
Show file tree
Hide file tree
Changes from 6 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
Expand Up @@ -31,13 +31,13 @@ export const STATIC_METADATA_IMAGES = {
filename: 'twitter-image',
extensions: ['jpg', 'jpeg', 'png', 'gif'],
},
}
} as const

// Produce all compositions with filename (icon, apple-icon, etc.) with extensions (png, jpg, etc.)
async function enumMetadataFiles(
dir: string,
filename: string,
extensions: string[],
extensions: readonly string[],
{
resolvePath,
loaderContext,
Expand Down
@@ -1,4 +1,4 @@
import type { RobotsFile } from '../../../../lib/metadata/types/metadata-interface'
import type { Robots } from '../../../../lib/metadata/types/metadata-interface'
import { resolveRobots, resolveSitemap } from './resolve-route-data'

describe('resolveRouteData', () => {
Expand Down Expand Up @@ -30,7 +30,7 @@ describe('resolveRouteData', () => {
})

it('should error with ts when specify both wildcard userAgent and specific userAgent', () => {
const data1: RobotsFile = {
const data1: Robots = {
rules: [
// @ts-expect-error userAgent is required for Array<Robots>
{
Expand All @@ -43,15 +43,14 @@ describe('resolveRouteData', () => {
],
}

const data2: RobotsFile = {
const data2: Robots = {
rules: {
// @ts-expect-error When apply only 1 rule, only '*' or undefined is allowed
userAgent: 'Somebot',
// Can skip userAgent for single Robots
allow: '/',
},
}

const data3: RobotsFile = {
const data3: Robots = {
rules: { allow: '/' },
}

Expand Down
@@ -1,11 +1,12 @@
import type {
RobotsFile,
SitemapFile,
Robots,
Sitemap,
} from '../../../../lib/metadata/types/metadata-interface'
import type { Manifest } from '../../../../lib/metadata/types/manifest-types'
import { resolveAsArrayOrUndefined } from '../../../../lib/metadata/generate/utils'

// convert robots data to txt string
export function resolveRobots(data: RobotsFile): string {
export function resolveRobots(data: Robots): string {
let content = ''
const rules = Array.isArray(data.rules) ? data.rules : [data.rules]
for (const rule of rules) {
Expand Down Expand Up @@ -40,7 +41,7 @@ export function resolveRobots(data: RobotsFile): string {

// TODO-METADATA: support multi sitemap files
// convert sitemap data to xml string
export function resolveSitemap(data: SitemapFile): string {
export function resolveSitemap(data: Sitemap): string {
let content = ''
content += '<?xml version="1.0" encoding="UTF-8"?>\n'
content += '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n'
Expand All @@ -60,15 +61,22 @@ export function resolveSitemap(data: SitemapFile): string {
return content
}

export function resolveManifest(data: Manifest): string {
return JSON.stringify(data)
}

export function resolveRouteData(
data: RobotsFile | SitemapFile,
fileType: 'robots' | 'sitemap'
data: Robots | Sitemap | Manifest,
fileType: 'robots' | 'sitemap' | 'manifest'
): string {
if (fileType === 'robots') {
return resolveRobots(data as RobotsFile)
return resolveRobots(data as Robots)
}
if (fileType === 'sitemap') {
return resolveSitemap(data as SitemapFile)
return resolveSitemap(data as Sitemap)
}
if (fileType === 'manifest') {
return resolveManifest(data as Manifest)
}
return ''
}
Expand Up @@ -20,6 +20,7 @@ function getContentType(resourcePath: string) {
if (name === 'favicon' && ext === 'ico') return 'image/x-icon'
if (name === 'sitemap') return 'application/xml'
if (name === 'robots') return 'text/plain'
if (name === 'manifest') return 'application/manifest+json'

if (ext === 'png' || ext === 'jpeg' || ext === 'ico' || ext === 'svg') {
return imageExtMimeTypeMap[ext]
Expand Down
3 changes: 3 additions & 0 deletions packages/next/src/lib/metadata/get-metadata-route.ts
Expand Up @@ -20,6 +20,9 @@ export function normalizeMetadataRoute(page: string) {
if (route === '/robots') {
route += '.txt'
}
if (route === '/manifest') {
route += '.webmanifest'
}
route = `${route}/route`
}
return route
Expand Down
17 changes: 13 additions & 4 deletions packages/next/src/lib/metadata/is-metadata-route.ts
Expand Up @@ -4,15 +4,15 @@ import { STATIC_METADATA_IMAGES } from '../../build/webpack/loaders/metadata/dis
// TODO-METADATA: support more metadata routes with more extensions
const defaultExtensions = ['js', 'jsx', 'ts', 'tsx']

const getExtensionRegexString = (extensions: string[]) =>
const getExtensionRegexString = (extensions: readonly string[]) =>
`(?:${extensions.join('|')})`

// When you only pass the file extension as `[]`, it will only match the static convention files
// e.g. /robots.txt, /sitemap.xml, /favicon.ico
// e.g. /robots.txt, /sitemap.xml, /favicon.ico, /manifest.json
// When you pass the file extension as `['js', 'jsx', 'ts', 'tsx']`, it will also match the dynamic convention files
// e.g. /robots.js, /sitemap.tsx, /favicon.jsx
// e.g. /robots.js, /sitemap.tsx, /favicon.jsx, /manifest.ts
// When `withExtension` is false, it will match the static convention files without the extension, by default it's true
// e.g. /robots, /sitemap, /favicon, use to match dynamic API routes like app/robots.ts
// e.g. /robots, /sitemap, /favicon, /manifest, use to match dynamic API routes like app/robots.ts
export function isMetadataRouteFile(
appDirRelativePath: string,
pageExtensions: string[],
Expand All @@ -33,6 +33,15 @@ export function isMetadataRouteFile(
: ''
}`
),
new RegExp(
`^[\\\\/]manifest${
withExtension
? `\\.${getExtensionRegexString(
pageExtensions.concat('webmanifest', 'json')
)}`
: ''
}`
),
new RegExp(`^[\\\\/]favicon\\.ico$`),
// TODO-METADATA: add dynamic routes for metadata images
new RegExp(
Expand Down
86 changes: 86 additions & 0 deletions packages/next/src/lib/metadata/types/manifest-types.ts
@@ -0,0 +1,86 @@
export type Manifest = {
background_color?: string
categories?: string[]
description?: string
display?: 'fullscreen' | 'standalone' | 'minimal-ui' | 'browser'
display_override?: string[]
icons?: {
src: string
type?: string
sizes?: string
purpose?: 'any' | 'maskable' | 'monochrome' | 'badge'
}[]
id?: string
launch_handler?: {
platform?: 'windows' | 'macos' | 'linux'
url?: string
}
name?: string
orientation?:
| 'any'
| 'natural'
| 'landscape'
| 'portrait'
| 'portrait-primary'
| 'portrait-secondary'
| 'landscape-primary'
| 'landscape-secondary'
prefer_related_applications?: boolean
protocol_handlers?: {
protocol: string
url: string
title?: string
}[]
related_applications?: {
platform: string
url: string
id?: string
}[]
scope?: string
screenshots?: {
src: string
type?: string
sizes?: string
}[]
serviceworker?: {
src?: string
scope?: string
type?: string
update_via_cache?: 'import' | 'none' | 'all'
}
share_target?: {
action?: string
method?: 'get' | 'post'
enctype?:
| 'application/x-www-form-urlencoded'
| 'multipart/form-data'
| 'text/plain'
params?: {
name: string
value: string
required?: boolean
}[]
url?: string
title?: string
text?: string
files?: {
accept?: string[]
name?: string
}[]
}
short_name?: string
shortcuts?: {
name: string
short_name?: string
description?: string
url: string
icons?: {
src: string
type?: string
sizes?: string
purpose?: 'any' | 'maskable' | 'monochrome' | 'badge'
}[]
}[]
start_url?: string
theme_color?: string
}
6 changes: 3 additions & 3 deletions packages/next/src/lib/metadata/types/metadata-interface.ts
Expand Up @@ -538,7 +538,7 @@ type RobotsFile = {
// Apply rules for all
rules:
| {
userAgent?: undefined | '*'
userAgent?: string | string[]
allow?: string | string[]
disallow?: string | string[]
crawlDelay?: number
Expand All @@ -554,10 +554,10 @@ type RobotsFile = {
host?: string
}

type SitemapFile = Array<{
type Sitemap = Array<{
url: string
lastModified?: string | Date
}>

export type ResolvingMetadata = Promise<ResolvedMetadata>
export { Metadata, ResolvedMetadata, RobotsFile, SitemapFile }
export { Metadata, ResolvedMetadata, RobotsFile as Robots, Sitemap }
1 change: 1 addition & 0 deletions packages/next/src/server/lib/find-page-file.ts
Expand Up @@ -97,6 +97,7 @@ export function createValidFileMatcher(
* /robots.txt|<ext>
* /sitemap.xml|<ext>
* /favicon.ico
* /manifest.json|<ext>
* <route>/icon.png|jpg|<ext>
* <route>/apple-touch-icon.png|jpg|<ext>
*
Expand Down
7 changes: 2 additions & 5 deletions packages/next/types/index.d.ts
Expand Up @@ -28,11 +28,8 @@ export type ServerRuntime = 'nodejs' | 'experimental-edge' | 'edge' | undefined
// @ts-ignore This path is generated at build time and conflicts otherwise
export { NextConfig } from '../dist/server/config'

export type {
Metadata,
RobotsFile,
SitemapFile, // @ts-ignore This path is generated at build time and conflicts otherwise
} from '../dist/lib/metadata/types/metadata-interface'
// @ts-ignore This path is generated at build time and conflicts otherwise
export type { Metadata } from '../dist/lib/metadata/types/metadata-interface'

// Extend the React types with missing properties
declare module 'react' {
Expand Down
18 changes: 18 additions & 0 deletions test/e2e/app-dir/metadata-dynamic-routes/app/manifest.ts
@@ -0,0 +1,18 @@
export default function manifest() {
return {
name: 'Next.js App',
short_name: 'Next.js App',
description: 'Next.js App',
start_url: '/',
display: 'standalone',
background_color: '#fff',
theme_color: '#fff',
icons: [
{
src: '/favicon.ico',
sizes: 'any',
type: 'image/x-icon',
},
],
}
}
4 changes: 1 addition & 3 deletions test/e2e/app-dir/metadata-dynamic-routes/app/robots.ts
@@ -1,6 +1,4 @@
import type { RobotsFile } from 'next'

export default function robots(): RobotsFile {
export default function robots() {
return {
rules: [
{
Expand Down
4 changes: 1 addition & 3 deletions test/e2e/app-dir/metadata-dynamic-routes/app/sitemap.ts
@@ -1,6 +1,4 @@
import type { SitemapFile } from 'next'

export default function sitemap(): SitemapFile {
export default function sitemap() {
return [
{
url: 'https://example.com',
Expand Down
29 changes: 29 additions & 0 deletions test/e2e/app-dir/metadata-dynamic-routes/index.test.ts
Expand Up @@ -56,6 +56,35 @@ createNextDescribe(
"
`)
})

it('should handle manifest.[ext] dynamic routes', async () => {
const res = await next.fetch('/manifest.webmanifest')
const json = await res.json()

expect(res.headers.get('content-type')).toBe(
'application/manifest+json'
)
expect(res.headers.get('cache-control')).toBe(
'public, max-age=0, must-revalidate'
)

expect(json).toMatchObject({
name: 'Next.js App',
short_name: 'Next.js App',
description: 'Next.js App',
start_url: '/',
display: 'standalone',
background_color: '#fff',
theme_color: '#fff',
icons: [
{
src: '/favicon.ico',
sizes: 'any',
type: 'image/x-icon',
},
],
})
})
})
}
)
2 changes: 1 addition & 1 deletion test/e2e/app-dir/metadata/app/basic/page.tsx
Expand Up @@ -25,7 +25,7 @@ export const metadata: Metadata = {
authors: [{ name: 'huozhi' }, { name: 'tree', url: 'https://tree.com' }],
themeColor: { color: 'cyan', media: '(prefers-color-scheme: dark)' },
colorScheme: 'dark',
manifest: 'https://github.com/manifest.json',
manifest: 'https://www.google.com/manifest',
viewport: {
width: 'device-width',
initialScale: 1,
Expand Down
9 changes: 9 additions & 0 deletions test/e2e/app-dir/metadata/app/manifest.webmanifest
@@ -0,0 +1,9 @@
{
"name": "Next.js Static Manifest",
"short_name": "Next.js App",
"description": "Next.js App",
"start_url": "/",
"display": "standalone",
"background_color": "#fff",
"theme_color": "#fff"
}