diff --git a/packages/next/src/build/webpack/loaders/metadata/discover.ts b/packages/next/src/build/webpack/loaders/metadata/discover.ts index 91e8fbb5ce61c..3081ded78e92c 100644 --- a/packages/next/src/build/webpack/loaders/metadata/discover.ts +++ b/packages/next/src/build/webpack/loaders/metadata/discover.ts @@ -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, diff --git a/packages/next/src/build/webpack/loaders/metadata/resolve-route-data.test.ts b/packages/next/src/build/webpack/loaders/metadata/resolve-route-data.test.ts index 684d94ddc697d..38261e75ee21c 100644 --- a/packages/next/src/build/webpack/loaders/metadata/resolve-route-data.test.ts +++ b/packages/next/src/build/webpack/loaders/metadata/resolve-route-data.test.ts @@ -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', () => { @@ -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 { @@ -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: '/' }, } diff --git a/packages/next/src/build/webpack/loaders/metadata/resolve-route-data.ts b/packages/next/src/build/webpack/loaders/metadata/resolve-route-data.ts index fbb39f7d7fabf..b2f1b1edaaba6 100644 --- a/packages/next/src/build/webpack/loaders/metadata/resolve-route-data.ts +++ b/packages/next/src/build/webpack/loaders/metadata/resolve-route-data.ts @@ -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) { @@ -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 += '\n' content += '\n' @@ -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 '' } diff --git a/packages/next/src/build/webpack/loaders/next-metadata-route-loader.ts b/packages/next/src/build/webpack/loaders/next-metadata-route-loader.ts index d465ec974508c..6b3797e8e2b13 100644 --- a/packages/next/src/build/webpack/loaders/next-metadata-route-loader.ts +++ b/packages/next/src/build/webpack/loaders/next-metadata-route-loader.ts @@ -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] diff --git a/packages/next/src/lib/metadata/get-metadata-route.ts b/packages/next/src/lib/metadata/get-metadata-route.ts index b7644e32b94b2..10b56b893d60d 100644 --- a/packages/next/src/lib/metadata/get-metadata-route.ts +++ b/packages/next/src/lib/metadata/get-metadata-route.ts @@ -20,6 +20,9 @@ export function normalizeMetadataRoute(page: string) { if (route === '/robots') { route += '.txt' } + if (route === '/manifest') { + route += '.webmanifest' + } route = `${route}/route` } return route diff --git a/packages/next/src/lib/metadata/is-metadata-route.ts b/packages/next/src/lib/metadata/is-metadata-route.ts index e58c978118a47..d23578befa0af 100644 --- a/packages/next/src/lib/metadata/is-metadata-route.ts +++ b/packages/next/src/lib/metadata/is-metadata-route.ts @@ -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[], @@ -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( diff --git a/packages/next/src/lib/metadata/types/manifest-types.ts b/packages/next/src/lib/metadata/types/manifest-types.ts new file mode 100644 index 0000000000000..cf683abfae8f5 --- /dev/null +++ b/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 +} diff --git a/packages/next/src/lib/metadata/types/metadata-interface.ts b/packages/next/src/lib/metadata/types/metadata-interface.ts index 10deb233e5b5d..bf4dd99bf2788 100644 --- a/packages/next/src/lib/metadata/types/metadata-interface.ts +++ b/packages/next/src/lib/metadata/types/metadata-interface.ts @@ -538,7 +538,7 @@ type RobotsFile = { // Apply rules for all rules: | { - userAgent?: undefined | '*' + userAgent?: string | string[] allow?: string | string[] disallow?: string | string[] crawlDelay?: number @@ -554,10 +554,10 @@ type RobotsFile = { host?: string } -type SitemapFile = Array<{ +type Sitemap = Array<{ url: string lastModified?: string | Date }> export type ResolvingMetadata = Promise -export { Metadata, ResolvedMetadata, RobotsFile, SitemapFile } +export { Metadata, ResolvedMetadata, RobotsFile as Robots, Sitemap } diff --git a/packages/next/src/server/lib/find-page-file.ts b/packages/next/src/server/lib/find-page-file.ts index 61f25b1d58be8..6cf514a834274 100644 --- a/packages/next/src/server/lib/find-page-file.ts +++ b/packages/next/src/server/lib/find-page-file.ts @@ -97,6 +97,7 @@ export function createValidFileMatcher( * /robots.txt| * /sitemap.xml| * /favicon.ico + * /manifest.json| * /icon.png|jpg| * /apple-touch-icon.png|jpg| * diff --git a/packages/next/types/index.d.ts b/packages/next/types/index.d.ts index 876d1a152d995..711cbacf434d7 100644 --- a/packages/next/types/index.d.ts +++ b/packages/next/types/index.d.ts @@ -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' { diff --git a/test/e2e/app-dir/metadata-dynamic-routes/app/manifest.ts b/test/e2e/app-dir/metadata-dynamic-routes/app/manifest.ts new file mode 100644 index 0000000000000..736cffed86b60 --- /dev/null +++ b/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', + }, + ], + } +} diff --git a/test/e2e/app-dir/metadata-dynamic-routes/app/robots.ts b/test/e2e/app-dir/metadata-dynamic-routes/app/robots.ts index 5a2bb37510ed0..749881098a1ea 100644 --- a/test/e2e/app-dir/metadata-dynamic-routes/app/robots.ts +++ b/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: [ { diff --git a/test/e2e/app-dir/metadata-dynamic-routes/app/sitemap.ts b/test/e2e/app-dir/metadata-dynamic-routes/app/sitemap.ts index 76a55cc17e47c..7b1426aa7ea45 100644 --- a/test/e2e/app-dir/metadata-dynamic-routes/app/sitemap.ts +++ b/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', diff --git a/test/e2e/app-dir/metadata-dynamic-routes/index.test.ts b/test/e2e/app-dir/metadata-dynamic-routes/index.test.ts index 43d9844c22d49..4b49a9e9e8467 100644 --- a/test/e2e/app-dir/metadata-dynamic-routes/index.test.ts +++ b/test/e2e/app-dir/metadata-dynamic-routes/index.test.ts @@ -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', + }, + ], + }) + }) }) } ) diff --git a/test/e2e/app-dir/metadata/app/basic/page.tsx b/test/e2e/app-dir/metadata/app/basic/page.tsx index b9366e712bea9..82f9f04e593a8 100644 --- a/test/e2e/app-dir/metadata/app/basic/page.tsx +++ b/test/e2e/app-dir/metadata/app/basic/page.tsx @@ -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, diff --git a/test/e2e/app-dir/metadata/app/manifest.webmanifest b/test/e2e/app-dir/metadata/app/manifest.webmanifest new file mode 100644 index 0000000000000..f3e287945395b --- /dev/null +++ b/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" +} diff --git a/test/e2e/app-dir/metadata/metadata.test.ts b/test/e2e/app-dir/metadata/metadata.test.ts index abff83ac1434e..473282c78df89 100644 --- a/test/e2e/app-dir/metadata/metadata.test.ts +++ b/test/e2e/app-dir/metadata/metadata.test.ts @@ -210,7 +210,7 @@ createNextDescribe( }) await matchMultiDom('link', 'rel', 'href', { - manifest: 'https://github.com/manifest.json', + manifest: 'https://www.google.com/manifest', author: 'https://tree.com', preconnect: '/preconnect-url', preload: '/preload-url', @@ -687,6 +687,23 @@ createNextDescribe( expect(invalidSitemapResponse.status).toBe(404) }) + it('should support static manifest.webmanifest', async () => { + const res = await next.fetch('/manifest.webmanifest') + expect(res.headers.get('content-type')).toBe( + 'application/manifest+json' + ) + const manifest = await res.json() + expect(manifest).toMatchObject({ + name: 'Next.js Static Manifest', + short_name: 'Next.js App', + description: 'Next.js App', + start_url: '/', + display: 'standalone', + background_color: '#fff', + theme_color: '#fff', + }) + }) + if (isNextStart) { it('should build favicon.ico as a custom route', async () => { const appPathsManifest = JSON.parse(