Skip to content

Commit

Permalink
Generate fixed route path for favicon.ico (#46997)
Browse files Browse the repository at this point in the history
Generate `/favicon.ico` route when favicon.ico is placed into `app/`.

Still collect favicon metadata image information through
metadata-image-loader but don't emit the file to static dist anymore.
Also collect favicon through metadata routes, and render it as static
routes. Also remove the `hash` we generated before, not needed anymore.

Change metadata static routes rendering process: collect static metadata
assets, read the buffer of the file data and return it in the response.

Closes NEXT-791
  • Loading branch information
huozhi authored Mar 10, 2023
1 parent d200e5f commit fd6b93d
Show file tree
Hide file tree
Showing 9 changed files with 91 additions and 55 deletions.
13 changes: 5 additions & 8 deletions packages/next/src/build/webpack/loaders/metadata/discover.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,12 @@
import type webpack from 'webpack'
import type { AppLoaderOptions } from '../next-app-loader'
import type { CollectingMetadata } from './types'
import type {
CollectingMetadata,
PossibleImageFileNameConvention,
} from './types'
import path from 'path'
import { stringify } from 'querystring'

type PossibleImageFileNameConvention =
| 'icon'
| 'apple'
| 'favicon'
| 'twitter'
| 'opengraph'

const METADATA_TYPE = 'metadata'

export const METADATA_IMAGE_RESOURCE_QUERY = '?__next_metadata'
Expand Down Expand Up @@ -127,6 +123,7 @@ export async function discoverStaticMetadataFiles(
...metadataImageLoaderOptions,
numericSizes:
type === 'twitter' || type === 'opengraph' ? '1' : undefined,
type,
})}!` +
filepath +
METADATA_IMAGE_RESOURCE_QUERY
Expand Down
7 changes: 7 additions & 0 deletions packages/next/src/build/webpack/loaders/metadata/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,10 @@ export type MetadataImageModule = {
height?: number
}
)

export type PossibleImageFileNameConvention =
| 'icon'
| 'apple'
| 'favicon'
| 'twitter'
| 'opengraph'
Original file line number Diff line number Diff line change
@@ -1,36 +1,42 @@
import type { MetadataImageModule } from './metadata/types'
import type {
MetadataImageModule,
PossibleImageFileNameConvention,
} from './metadata/types'
import loaderUtils from 'next/dist/compiled/loader-utils3'
import { getImageSize } from '../../../server/image-optimizer'

interface Options {
isDev: boolean
assetPrefix: string
numericSizes: boolean
type: PossibleImageFileNameConvention
}

const mimeTypeMap = {
jpeg: 'image/jpeg',
png: 'image/png',
ico: 'image/x-icon',
svg: 'image/svg+xml',
avif: 'image/avif',
webp: 'image/webp',
}

async function nextMetadataImageLoader(this: any, content: Buffer) {
const options: Options = this.getOptions()
const { assetPrefix, isDev, numericSizes } = options
const { assetPrefix, isDev, numericSizes, type } = options
const context = this.rootContext

const opts = { context, content }

// favicon is the special case, always generate '/favicon.ico'
const isFavIcon = type === 'favicon'
// e.g. icon.png -> server/static/media/metadata/icon.399de3b9.png
const interpolatedName = loaderUtils.interpolateName(
this,
'/static/media/metadata/[name].[hash:8].[ext]',
(isFavIcon ? '' : '/static/media/metadata/') + '[name].[ext]',
opts
)
const outputPath = assetPrefix + '/_next' + interpolatedName
const outputPath = isFavIcon
? '/' + interpolatedName
: assetPrefix + '/_next' + interpolatedName

let extension = loaderUtils.interpolateName(this, '[ext]', opts)
if (extension === 'jpg') {
extension = 'jpeg'
Expand Down Expand Up @@ -61,7 +67,10 @@ async function nextMetadataImageLoader(this: any, content: Buffer) {

const stringifiedData = JSON.stringify(imageData)

this.emitFile(`../${isDev ? '' : '../'}${interpolatedName}`, content, null)
// TODO-METADATA: Move image generation to static app routes
if (!isFavIcon) {
this.emitFile(`../${isDev ? '' : '../'}${interpolatedName}`, content, null)
}

return `export default ${stringifiedData};`
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,32 +1,32 @@
import type webpack from 'webpack'
import path from 'path'

const staticFileRegex = /[\\/](robots\.txt|sitemap\.xml)/

function isStaticRoute(resourcePath: string) {
return staticFileRegex.test(resourcePath)
}
import { isStaticMetadataRoute } from '../../../lib/is-app-route-route'

function getContentType(resourcePath: string) {
const filename = path.basename(resourcePath)
const [name] = filename.split('.')
const [name, ext] = filename.split('.')
if (name === 'favicon' && ext === 'ico') return 'image/x-icon'
if (name === 'sitemap') return 'application/xml'
if (name === 'robots') return 'text/plain'
return 'text/plain'
}

const nextMetadataRouterLoader: webpack.LoaderDefinitionFunction = function (
content: string
) {
// `import.meta.url` is the resource name of the current module.
// When it's static route, it could be favicon.ico, sitemap.xml, robots.txt etc.
const nextMetadataRouterLoader: webpack.LoaderDefinitionFunction = function () {
const { resourcePath } = this

const code = isStaticRoute(resourcePath)
const code = isStaticMetadataRoute(resourcePath)
? `import { NextResponse } from 'next/server'
import fs from 'fs'
import path from 'path'
import { fileURLToPath } from 'url'
const content = ${JSON.stringify(content)}
const buffer = fs.readFileSync(fileURLToPath(import.meta.url))
const contentType = ${JSON.stringify(getContentType(resourcePath))}
export function GET() {
return new NextResponse(content, {
return new NextResponse(buffer, {
status: 200,
headers: {
'Content-Type': contentType,
Expand Down
7 changes: 6 additions & 1 deletion packages/next/src/build/webpack/stringify-request.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
export function stringifyRequest(loaderContext: any, request: any) {
import type webpack from 'webpack'

export function stringifyRequest(
loaderContext: webpack.LoaderContext<any>,
request: string
) {
return JSON.stringify(
loaderContext.utils.contextify(
loaderContext.context || loaderContext.rootContext,
Expand Down
14 changes: 12 additions & 2 deletions packages/next/src/lib/is-app-route-route.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,21 @@
import path from '../shared/lib/isomorphic/path'

export function isAppRouteRoute(route: string): boolean {
return route.endsWith('/route')
}

// TODO: support more metadata routes
const staticMetadataRoutes = ['robots.txt', 'sitemap.xml']
// Match routes that are metadata routes, e.g. /sitemap.xml, /favicon.<ext>, /<icon>.<ext>, etc.
// TODO-METADATA: support more metadata routes with more extensions
const staticMetadataRoutes = ['robots.txt', 'sitemap.xml', 'favicon.ico']
export function isMetadataRoute(route: string): boolean {
// Remove the 'app/' or '/' prefix, only check the route name since they're only allowed in root app directory
const filename = route.replace(/^app\//, '').replace(/^\//, '')
return staticMetadataRoutes.includes(filename)
}

// Only match the static metadata files
// TODO-METADATA: support static metadata files under nested routes folders
export function isStaticMetadataRoute(resourcePath: string) {
const filename = path.basename(resourcePath)
return staticMetadataRoutes.includes(filename)
}
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ export class DevAppRouteRouteMatcherProvider extends FileCacheRouteMatcherProvid
.concat('txt')
.join('|')})?$|[/\\\\]sitemap\\.(?:${extensions
.concat('xml')
.join('|')})?$`
.join('|')})?$|[/\\\\]favicon\\.ico$`
)

const pageNormalizer = new AbsoluteFilenameNormalizer(appDir, extensions)
Expand Down
28 changes: 21 additions & 7 deletions packages/next/src/server/lib/find-page-file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,19 +79,33 @@ export function createValidFileMatcher(
pageExtensions: string[],
appDirPath: string | undefined
) {
const getExtensionRegexString = (
extensions: string[],
...extraExtensions: string[]
) => `(?:${extensions.concat(extraExtensions).join('|')})`

const validExtensionFileRegex = new RegExp(
`\\.+(?:${pageExtensions.join('|')})$`
'\\.' + getExtensionRegexString(pageExtensions) + '$'
)
const leafOnlyPageFileRegex = new RegExp(
`(^(page|route)|[\\\\/](page|route))\\.(?:${pageExtensions.join('|')})$`
`(^(page|route)|[\\\\/](page|route))\\.${getExtensionRegexString(
pageExtensions
)}$`
)
// TODO: support other metadata routes
// regex for /robots.txt|((j|t)sx?)
// regex for /sitemap.xml|((j|t)sx?)
/** TODO-METADATA: support other metadata routes
* regex for:
*
* /robots.txt|((j|t)sx?)
* /sitemap.xml|((j|t)sx?)
* /favicon.ico
*
*/
const metadataRoutesRelativePathRegex = new RegExp(
`^[\\\\/](robots)\\.(?:${pageExtensions.concat('txt').join('|')})$` +
`^[\\\\/]((robots\\.${getExtensionRegexString(pageExtensions, 'txt')})` +
'|' +
`(sitemap\\.${getExtensionRegexString(pageExtensions, 'xml')})` +
'|' +
`^[\\\\/](sitemap)\\.(?:${pageExtensions.concat('xml').join('|')})$`
`(favicon\\.ico))$`
)

function isMetadataRouteFile(filePath: string) {
Expand Down
24 changes: 9 additions & 15 deletions test/e2e/app-dir/metadata/metadata.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -428,7 +428,7 @@ createNextDescribe(
it('should pick up opengraph-image and twitter-image as static metadata files', async () => {
const $ = await next.render$('/opengraph/static')
expect($('[property="og:image"]').attr('content')).toMatch(
/https:\/\/example.com\/_next\/static\/media\/metadata\/opengraph-image.\w+.png/
/https:\/\/example.com\/_next\/static\/media\/metadata\/opengraph-image.png/
)
expect($('[property="og:image:type"]').attr('content')).toBe(
'image/png'
Expand All @@ -437,17 +437,15 @@ createNextDescribe(
expect($('[property="og:image:height"]').attr('content')).toBe('114')

expect($('[name="twitter:image"]').attr('content')).toMatch(
/https:\/\/example.com\/_next\/static\/media\/metadata\/twitter-image.\w+.png/
/https:\/\/example.com\/_next\/static\/media\/metadata\/twitter-image.png/
)
expect($('[name="twitter:card"]').attr('content')).toBe(
'summary_large_image'
)

// favicon shouldn't be overridden
const $icon = $('link[rel="icon"]')
expect($icon.attr('href')).toMatch(
/_next\/static\/media\/metadata\/favicon.\w+.ico/
)
expect($icon.attr('href')).toBe('/favicon.ico')
})
})

Expand Down Expand Up @@ -497,17 +495,13 @@ createNextDescribe(
it('should support root level of favicon.ico', async () => {
let $ = await next.render$('/')
let $icon = $('link[rel="icon"]')
expect($icon.attr('href')).toMatch(
/_next\/static\/media\/metadata\/favicon.\w+.ico/
)
expect($icon.attr('href')).toBe('/favicon.ico')
expect($icon.attr('type')).toBe('image/x-icon')
expect($icon.attr('sizes')).toBe('any')

$ = await next.render$('/basic')
$icon = $('link[rel="icon"]')
expect($icon.attr('href')).toMatch(
/_next\/static\/media\/metadata\/favicon.\w+.ico/
)
expect($icon.attr('href')).toBe('/favicon.ico')
expect($icon.attr('sizes')).toBe('any')
})
})
Expand All @@ -520,12 +514,12 @@ createNextDescribe(
const $appleIcon = $('head > link[rel="apple-touch-icon"]')

expect($icon.attr('href')).toMatch(
/\/_next\/static\/media\/metadata\/icon1\.\w+\.png/
/\/_next\/static\/media\/metadata\/icon1\.png/
)
expect($icon.attr('sizes')).toBe('32x32')
expect($icon.attr('type')).toBe('image/png')
expect($appleIcon.attr('href')).toMatch(
/\/_next\/static\/media\/metadata\/apple-icon\.\w+\.png/
/\/_next\/static\/media\/metadata\/apple-icon\.png/
)
expect($appleIcon.attr('type')).toBe('image/png')
expect($appleIcon.attr('sizes')).toMatch('114x114')
Expand All @@ -538,7 +532,7 @@ createNextDescribe(
const $appleIcon = $('head > link[rel="apple-touch-icon"]')

expect($icon.attr('href')).toMatch(
/\/_next\/static\/media\/metadata\/icon\.\w+\.png/
/\/_next\/static\/media\/metadata\/icon\.png/
)
expect($icon.attr('sizes')).toBe('114x114')

Expand All @@ -556,7 +550,7 @@ createNextDescribe(
const $ = await next.render$('/icons/static')
const $icon = $('head > link[rel="icon"][type!="image/x-icon"]')
return $icon.attr('href')
}, /\/_next\/static\/media\/metadata\/icon2\.\w+\.png/)
}, /\/_next\/static\/media\/metadata\/icon2\.png/)

await next.renameFile(
'app/icons/static/icon2.png',
Expand Down

0 comments on commit fd6b93d

Please sign in to comment.