Skip to content

Commit

Permalink
Support manifest.json static and dynamic route (#47240)
Browse files Browse the repository at this point in the history
* Add `manifest.webmanifest` and `manifest.(j|t)xs?` support for
manifest.json route
* Add `Manifest` type for it for autocomplete purpose.

Remove the exports for `SitemapFile` and `RobotsFile` globally, will
discuss how to re-export them with better naming later

Small fix for `Robots` typing, should allow `string | string[]` for user
agent of single Robots

Closes NEXT-839

---------
  • Loading branch information
huozhi authored Mar 17, 2023
1 parent 990f62a commit e601a3b
Show file tree
Hide file tree
Showing 17 changed files with 209 additions and 36 deletions.
4 changes: 2 additions & 2 deletions packages/next/src/build/webpack/loaders/metadata/discover.ts
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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 ''
}
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
@@ -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"
}
Loading

0 comments on commit e601a3b

Please sign in to comment.