next.js/packages/next/src/lib/route-pattern-normalizer.ts
route-pattern-normalizer.ts145 lines5.1 KB
import type { Token } from 'next/dist/compiled/path-to-regexp'

/**
 * Route pattern normalization utilities for path-to-regexp compatibility.
 *
 * path-to-regexp 6.3.0+ introduced stricter validation that rejects certain
 * patterns commonly used in Next.js interception routes. This module provides
 * normalization functions to make Next.js route patterns compatible with the
 * updated library while preserving all functionality.
 */

/**
 * Internal separator used to normalize adjacent parameter patterns.
 * This unique marker is inserted between adjacent parameters and stripped out
 * during parameter extraction to avoid conflicts with real URL content.
 */
export const PARAM_SEPARATOR = '_NEXTSEP_'

/**
 * Detects if a route pattern needs normalization for path-to-regexp compatibility.
 */
export function hasAdjacentParameterIssues(route: string): boolean {
  if (typeof route !== 'string') return false

  // Check for interception route markers followed immediately by parameters
  // Pattern: /(.):param, /(..):param, /(...):param, /(.)(.):param etc.
  // These patterns cause "Must have text between two parameters" errors
  if (/\/\(\.{1,3}\):[^/\s]+/.test(route)) {
    return true
  }

  // Check for basic adjacent parameters without separators
  // Pattern: :param1:param2 (but not :param* or other URL patterns)
  if (/:[a-zA-Z_][a-zA-Z0-9_]*:[a-zA-Z_][a-zA-Z0-9_]*/.test(route)) {
    return true
  }

  return false
}

/**
 * Normalizes route patterns that have adjacent parameters without text between them.
 * Inserts a unique separator that can be safely stripped out later.
 */
export function normalizeAdjacentParameters(route: string): string {
  let normalized = route

  // Handle interception route patterns: (.):param -> (.)_NEXTSEP_:param
  normalized = normalized.replace(
    /(\([^)]*\)):([^/\s]+)/g,
    `$1${PARAM_SEPARATOR}:$2`
  )

  // Handle other adjacent parameter patterns: :param1:param2 -> :param1_NEXTSEP_:param2
  normalized = normalized.replace(/:([^:/\s)]+)(?=:)/g, `:$1${PARAM_SEPARATOR}`)

  return normalized
}

/**
 * Normalizes tokens that have repeating modifiers (* or +) but empty prefix and suffix.
 *
 * path-to-regexp 6.3.0+ introduced validation that throws:
 * "Can not repeat without prefix/suffix"
 *
 * This occurs when a token has modifier: '*' or '+' with both prefix: '' and suffix: ''
 */
export function normalizeTokensForRegexp(tokens: Token[]): Token[] {
  return tokens.map((token) => {
    // Token union type: Token = string | TokenObject
    // Literal path segments are strings, parameters/wildcards are objects
    if (
      typeof token === 'object' &&
      token !== null &&
      // Not all token objects have 'modifier' property (e.g., simple text tokens)
      'modifier' in token &&
      // Only repeating modifiers (* or +) cause the validation error
      // Other modifiers like '?' (optional) are fine
      (token.modifier === '*' || token.modifier === '+') &&
      // Token objects can have different shapes depending on route pattern
      'prefix' in token &&
      'suffix' in token &&
      // Both prefix and suffix must be empty strings
      // This is what causes the validation error in path-to-regexp
      token.prefix === '' &&
      token.suffix === ''
    ) {
      // Add minimal prefix to satisfy path-to-regexp validation
      // We use '/' as it's the most common path delimiter and won't break route matching
      // The prefix gets used in regex generation but doesn't affect parameter extraction
      return {
        ...token,
        prefix: '/',
      }
    }
    return token
  })
}

/**
 * Strips normalization separators from compiled pathname.
 * This removes separators that were inserted by normalizeAdjacentParameters
 * to satisfy path-to-regexp validation.
 *
 * Only removes separators in the specific contexts where they were inserted:
 * - After interception route markers: (.)_NEXTSEP_ -> (.)
 *
 * This targeted approach ensures we don't accidentally remove the separator
 * from legitimate user content.
 */
export function stripNormalizedSeparators(pathname: string): string {
  // Remove separator after interception route markers
  // Pattern: (.)_NEXTSEP_ -> (.), (..)_NEXTSEP_ -> (..), etc.
  // The separator appears after the closing paren of interception markers
  return pathname.replace(new RegExp(`\\)${PARAM_SEPARATOR}`, 'g'), ')')
}

/**
 * Strips normalization separators from extracted route parameters.
 * Used by both server and client code to clean up parameters after route matching.
 */
export function stripParameterSeparators(
  params: Record<string, any>
): Record<string, any> {
  const cleaned: Record<string, any> = {}

  for (const [key, value] of Object.entries(params)) {
    if (typeof value === 'string') {
      // Remove the separator if it appears at the start of parameter values
      cleaned[key] = value.replace(new RegExp(`^${PARAM_SEPARATOR}`), '')
    } else if (Array.isArray(value)) {
      // Handle array parameters (from repeated route segments)
      cleaned[key] = value.map((item) =>
        typeof item === 'string'
          ? item.replace(new RegExp(`^${PARAM_SEPARATOR}`), '')
          : item
      )
    } else {
      cleaned[key] = value
    }
  }

  return cleaned
}
Quest for Codev2.0.0
/
SIGN IN