next.js/packages/next/src/lib/metadata/is-metadata-route.ts
is-metadata-route.ts264 lines8.4 KB
import type { PageExtensions } from '../../build/page-extensions-type'
import { normalizePathSep } from '../../shared/lib/page-path/normalize-path-sep'
import { normalizeAppPath } from '../../shared/lib/router/utils/app-paths'
import { isAppRouteRoute } from '../is-app-route-route'

export const STATIC_METADATA_IMAGES = {
  icon: {
    filename: 'icon',
    extensions: ['ico', 'jpg', 'jpeg', 'png', 'svg'],
  },
  apple: {
    filename: 'apple-icon',
    extensions: ['jpg', 'jpeg', 'png'],
  },
  favicon: {
    filename: 'favicon',
    extensions: ['ico'],
  },
  openGraph: {
    filename: 'opengraph-image',
    extensions: ['jpg', 'jpeg', 'png', 'gif'],
  },
  twitter: {
    filename: 'twitter-image',
    extensions: ['jpg', 'jpeg', 'png', 'gif'],
  },
} as const

// Match routes that are metadata routes, e.g. /sitemap.xml, /favicon.<ext>, /<icon>.<ext>, etc.
// TODO-METADATA: support more metadata routes with more extensions
export const DEFAULT_METADATA_ROUTE_EXTENSIONS = ['js', 'jsx', 'ts', 'tsx']

// Match the file extension with the dynamic multi-routes extensions
// e.g. ([xml, js], null) -> can match `/sitemap.xml/route`, `sitemap.js/route`
// e.g. ([png], [ts]) -> can match `/opengraph-image.png`, `/opengraph-image.ts`
export const getExtensionRegexString = (
  staticExtensions: readonly string[],
  dynamicExtensions: readonly string[] | null
) => {
  let result: string
  // If there's no possible multi dynamic routes, will not match any <name>[].<ext> files
  if (!dynamicExtensions || dynamicExtensions.length === 0) {
    result = `(\\.(?:${staticExtensions.join('|')}))`
  } else {
    result = `(?:\\.(${staticExtensions.join('|')})|(\\.(${dynamicExtensions.join('|')})))`
  }
  return result
}

/**
 * Matches the static metadata files, e.g. /robots.txt, /sitemap.xml, /favicon.ico, etc.
 * @param appDirRelativePath the relative file path to app/
 * @returns if the path is a static metadata file route
 */
export function isStaticMetadataFile(appDirRelativePath: string) {
  return isMetadataRouteFile(appDirRelativePath, [], true)
}

// Pre-compiled static regexes for common cases
const FAVICON_REGEX = /^[\\/]favicon\.ico$/
const ROBOTS_TXT_REGEX = /^[\\/]robots\.txt$/
const MANIFEST_JSON_REGEX = /^[\\/]manifest\.json$/
const MANIFEST_WEBMANIFEST_REGEX = /^[\\/]manifest\.webmanifest$/
const SITEMAP_XML_REGEX = /[\\/]sitemap\.xml$/

// Cache for compiled regex patterns based on parameters
const compiledRegexCache = new Map<string, RegExp[]>()

// Fast path checks for common metadata files
function fastPathCheck(normalizedPath: string): boolean | null {
  // Check favicon.ico first (most common)
  if (FAVICON_REGEX.test(normalizedPath)) return true

  // Check other common static files
  if (ROBOTS_TXT_REGEX.test(normalizedPath)) return true
  if (MANIFEST_JSON_REGEX.test(normalizedPath)) return true
  if (MANIFEST_WEBMANIFEST_REGEX.test(normalizedPath)) return true
  if (SITEMAP_XML_REGEX.test(normalizedPath)) return true

  // Quick negative check - if it doesn't contain any metadata keywords, skip
  if (
    !normalizedPath.includes('robots') &&
    !normalizedPath.includes('manifest') &&
    !normalizedPath.includes('sitemap') &&
    !normalizedPath.includes('icon') &&
    !normalizedPath.includes('apple-icon') &&
    !normalizedPath.includes('opengraph-image') &&
    !normalizedPath.includes('twitter-image') &&
    !normalizedPath.includes('favicon')
  ) {
    return false
  }

  return null // Continue with full regex matching
}

function getCompiledRegexes(
  pageExtensions: PageExtensions,
  strictlyMatchExtensions: boolean
): RegExp[] {
  // Create cache key
  const cacheKey = `${pageExtensions.join(',')}|${strictlyMatchExtensions}`

  const cached = compiledRegexCache.get(cacheKey)
  if (cached) {
    return cached
  }

  // Pre-compute common strings
  const trailingMatcher = strictlyMatchExtensions ? '$' : '?$'
  const variantsMatcher = '\\d?'
  const groupSuffix = strictlyMatchExtensions ? '' : '(-\\w{6})?'
  const suffixMatcher = variantsMatcher + groupSuffix

  // Pre-compute extension arrays to avoid repeated concatenation
  const robotsExts =
    pageExtensions.length > 0 ? [...pageExtensions, 'txt'] : ['txt']
  const manifestExts =
    pageExtensions.length > 0
      ? [...pageExtensions, 'webmanifest', 'json']
      : ['webmanifest', 'json']

  const regexes = [
    new RegExp(
      `^[\\\\/]robots${getExtensionRegexString(robotsExts, null)}${trailingMatcher}`
    ),
    new RegExp(
      `^[\\\\/]manifest${getExtensionRegexString(manifestExts, null)}${trailingMatcher}`
    ),
    // FAVICON_REGEX removed - already handled in fastPathCheck
    new RegExp(
      `[\\\\/]sitemap${getExtensionRegexString(['xml'], pageExtensions)}${trailingMatcher}`
    ),
    new RegExp(
      `[\\\\/]icon${suffixMatcher}${getExtensionRegexString(
        STATIC_METADATA_IMAGES.icon.extensions,
        pageExtensions
      )}${trailingMatcher}`
    ),
    new RegExp(
      `[\\\\/]apple-icon${suffixMatcher}${getExtensionRegexString(
        STATIC_METADATA_IMAGES.apple.extensions,
        pageExtensions
      )}${trailingMatcher}`
    ),
    new RegExp(
      `[\\\\/]opengraph-image${suffixMatcher}${getExtensionRegexString(
        STATIC_METADATA_IMAGES.openGraph.extensions,
        pageExtensions
      )}${trailingMatcher}`
    ),
    new RegExp(
      `[\\\\/]twitter-image${suffixMatcher}${getExtensionRegexString(
        STATIC_METADATA_IMAGES.twitter.extensions,
        pageExtensions
      )}${trailingMatcher}`
    ),
  ]

  compiledRegexCache.set(cacheKey, regexes)
  return regexes
}

/**
 * Determine if the file is a metadata route file entry
 * @param appDirRelativePath the relative file path to app/
 * @param pageExtensions the js extensions, such as ['js', 'jsx', 'ts', 'tsx']
 * @param strictlyMatchExtensions if it's true, match the file with page extension, otherwise match the file with default corresponding extension
 * @returns if the file is a metadata route file
 */
export function isMetadataRouteFile(
  appDirRelativePath: string,
  pageExtensions: PageExtensions,
  strictlyMatchExtensions: boolean
): boolean {
  // Early exit for empty or obviously non-metadata paths
  if (!appDirRelativePath || appDirRelativePath.length < 2) {
    return false
  }

  const normalizedPath = normalizePathSep(appDirRelativePath)

  // Fast path check for common cases
  const fastResult = fastPathCheck(normalizedPath)
  if (fastResult !== null) {
    return fastResult
  }

  // Get compiled regexes from cache
  const regexes = getCompiledRegexes(pageExtensions, strictlyMatchExtensions)

  // Use for loop instead of .some() for better performance
  for (let i = 0; i < regexes.length; i++) {
    if (regexes[i].test(normalizedPath)) {
      return true
    }
  }

  return false
}

// Check if the route is a static metadata route, with /route suffix
// e.g. /favicon.ico/route, /icon.png/route, etc.
// But skip the text routes like robots.txt since they might also be dynamic.
// Checking route path is not enough to determine if text routes is dynamic.
export function isStaticMetadataRoute(route: string) {
  // extract ext with regex
  const pathname = route.replace(/\/route$/, '')

  const matched =
    isAppRouteRoute(route) &&
    isMetadataRouteFile(pathname, [], true) &&
    // These routes can either be built by static or dynamic entrypoints,
    // so we assume they're dynamic
    pathname !== '/robots.txt' &&
    pathname !== '/manifest.webmanifest' &&
    !pathname.endsWith('/sitemap.xml')

  return matched
}

/**
 * Determine if a page or pathname is a metadata page.
 *
 * The input is a page or pathname, which can be with or without page suffix /foo/page or /foo.
 * But it will not contain the /route suffix.
 *
 * .e.g
 * /robots -> true
 * /sitemap -> true
 * /foo -> false
 */
export function isMetadataPage(page: string) {
  const matched = !isAppRouteRoute(page) && isMetadataRouteFile(page, [], false)

  return matched
}

/*
 * Determine if a Next.js route is a metadata route.
 * `route` will has a route suffix.
 *
 * e.g.
 * /app/robots/route -> true
 * /robots/route -> true
 * /sitemap/[__metadata_id__]/route -> true
 * /app/sitemap/page -> false
 * /icon-a102f4/route -> true
 */
export function isMetadataRoute(route: string): boolean {
  let page = normalizeAppPath(route)
    .replace(/^\/?app\//, '')
    // Remove the dynamic route id
    .replace('/[__metadata_id__]', '')
    // Remove the /route suffix
    .replace(/\/route$/, '')

  if (page[0] !== '/') page = '/' + page

  const matched = isAppRouteRoute(route) && isMetadataRouteFile(page, [], false)

  return matched
}
Quest for Codev2.0.0
/
SIGN IN