next.js/packages/next/src/server/lib/find-page-file.ts
find-page-file.ts175 lines5.7 KB
import { fileExists } from '../../lib/file-exists'
import { getPagePaths } from '../../shared/lib/page-path/get-page-paths'
import { nonNullable } from '../../lib/non-nullable'
import { join, sep, normalize } from 'path'
import { promises as fsPromises } from 'fs'
import { warn } from '../../build/output/log'
import { cyan } from '../../lib/picocolors'
import { isMetadataRouteFile } from '../../lib/metadata/is-metadata-route'
import { escapeStringRegexp } from '../../shared/lib/escape-regexp'
import type { PageExtensions } from '../../build/page-extensions-type'

async function isTrueCasePagePath(pagePath: string, pagesDir: string) {
  const pageSegments = normalize(pagePath).split(sep).filter(Boolean)
  const segmentExistsPromises = pageSegments.map(async (segment, i) => {
    const segmentParentDir = join(pagesDir, ...pageSegments.slice(0, i))
    const parentDirEntries = await fsPromises.readdir(segmentParentDir)
    return parentDirEntries.includes(segment)
  })

  return (await Promise.all(segmentExistsPromises)).every(Boolean)
}

/**
 * Finds a page file with the given parameters. If the page is duplicated with
 * multiple extensions it will throw, otherwise it will return the *relative*
 * path to the page file or null if it is not found.
 *
 * @param pagesDir Absolute path to the pages folder with trailing `/pages`.
 * @param normalizedPagePath The page normalized (it will be denormalized).
 * @param pageExtensions Array of page extensions.
 */
export async function findPageFile(
  pagesDir: string,
  normalizedPagePath: string,
  pageExtensions: PageExtensions,
  isAppDir: boolean
): Promise<string | null> {
  const pagePaths = getPagePaths(normalizedPagePath, pageExtensions, isAppDir)
  const [existingPath, ...others] = (
    await Promise.all(
      pagePaths.map(async (path) => {
        const filePath = join(pagesDir, path)
        try {
          return (await fileExists(filePath)) ? path : null
        } catch (err: any) {
          if (!err?.code?.includes('ENOTDIR')) throw err
        }
        return null
      })
    )
  ).filter(nonNullable)

  if (!existingPath) {
    return null
  }

  if (!(await isTrueCasePagePath(existingPath, pagesDir))) {
    return null
  }

  if (others.length > 0) {
    warn(
      `Duplicate page detected. ${cyan(join('pages', existingPath))} and ${cyan(
        join('pages', others[0])
      )} both resolve to ${cyan(normalizedPagePath)}.`
    )
  }

  return existingPath
}

/**
 *
 * createValidFileMatcher receives configured page extensions and return helpers to determine:
 * `isLayoutsLeafPage`: if a file is a valid page file or routes file under app directory
 * `isTrackedFiles`: if it's a tracked file for our file watcher
 *
 */
export function createValidFileMatcher(
  pageExtensions: PageExtensions,
  appDirPath: string | undefined
) {
  // Helper to create extension regex pattern
  const extPattern = `(?:${pageExtensions
    .map((extension) => escapeStringRegexp(extension))
    .join('|')})`

  // Pattern factory for "leaf" files that can appear at start of path or after separator
  // e.g., 'page.tsx', '/path/page.tsx', '\\path\\route.js'
  const createLeafPattern = (fileNames: string[]): RegExp => {
    const names =
      fileNames.length === 1 ? fileNames[0] : `(${fileNames.join('|')})`
    return new RegExp(`(^${names}|[\\\\/]${names})\\.${extPattern}$`)
  }

  // Pattern factory for root-only files (no path separator allowed)
  const createRootOnlyPattern = (fileName: string): RegExp =>
    new RegExp(`^${fileName}\\.${extPattern}$`)

  // All file matching patterns
  const validExtensionFileRegex = new RegExp(`\\.${extPattern}$`)
  const leafOnlyPageFileRegex = createLeafPattern(['page', 'route'])
  const leafOnlyRouteFileRegex = createLeafPattern(['route'])
  const leafOnlyLayoutFileRegex = createLeafPattern(['layout'])
  const leafOnlyDefaultFileRegex = createLeafPattern(['default'])
  const rootNotFoundFileRegex = createRootOnlyPattern('not-found')
  /** TODO-METADATA: support other metadata routes
   *  regex for:
   *
   * /robots.txt|<ext>
   * /sitemap.xml|<ext>
   * /favicon.ico
   * /manifest.json|<ext>
   * <route>/icon.png|jpg|<ext>
   * <route>/apple-touch-icon.png|jpg|<ext>
   *
   */

  /**
   * Match the file if it's a metadata route file, static: if the file is a static metadata file.
   * It needs to be a file which doesn't match the custom metadata routes e.g. `app/robots.txt/route.js`
   */
  function isMetadataFile(filePath: string) {
    const appDirRelativePath = appDirPath
      ? filePath.replace(appDirPath, '')
      : filePath

    return isMetadataRouteFile(appDirRelativePath, pageExtensions, true)
  }

  // Determine if the file is leaf node page file or route file under layouts,
  // 'page.<extension>' | 'route.<extension>'
  function isAppRouterPage(filePath: string) {
    return leafOnlyPageFileRegex.test(filePath) || isMetadataFile(filePath)
  }

  // Determine if the file is leaf node route file under app directory
  function isAppRouterRoute(filePath: string) {
    return leafOnlyRouteFileRegex.test(filePath)
  }

  function isAppLayoutPage(filePath: string) {
    return leafOnlyLayoutFileRegex.test(filePath)
  }

  function isAppDefaultPage(filePath: string) {
    return leafOnlyDefaultFileRegex.test(filePath)
  }

  function isPageFile(filePath: string) {
    return validExtensionFileRegex.test(filePath) || isMetadataFile(filePath)
  }

  function isRootNotFound(filePath: string) {
    if (!appDirPath) {
      return false
    }
    if (!filePath.startsWith(appDirPath + sep)) {
      return false
    }
    const rest = filePath.slice(appDirPath.length + 1)
    return rootNotFoundFileRegex.test(rest)
  }

  return {
    isPageFile,
    isAppRouterPage,
    isAppRouterRoute,
    isAppLayoutPage,
    isAppDefaultPage,
    isMetadataFile,
    isRootNotFound,
  }
}
Quest for Codev2.0.0
/
SIGN IN