import { Key as PtrKey, match as ptrMatch, parse as ptrParse } from 'path-to-regexp' import type { ParsedUrlQuery } from 'querystring' import type { UrlObject } from 'url' import { ignoreSegmentPathRegex, anyDynamicPathPatternPartRegex, anyDynamicFilepathPartsRegex } from '../shared/regex' import type { TRouteBranch } from '../types' import { getNtrData } from './ntrData' import { removeLangPrefix } from './removeLangPrefix' import { urlToUrlObject } from './urlToUrlObject' enum MATCH_TYPE { STATIC = 'static', DYNAMIC = 'dynamic', MATCHALL = 'match-all', } type TParsedPathParts = { additionalQuery: ParsedUrlQuery; parsedPathParts: string[]; firstMatchType: MATCH_TYPE } const getEndFilepathParts = ({ children = [] }: TRouteBranch, locale: string): TParsedPathParts | undefined => { for (const child of children) { const path = child.paths[locale] || child.paths.default if (path === 'index') { return { parsedPathParts: [], additionalQuery: {}, firstMatchType: MATCH_TYPE.STATIC } } if (ignoreSegmentPathRegex.test(path) && child.children?.length) { const descendantResult = getEndFilepathParts(child, locale) if (descendantResult) { return { ...descendantResult, parsedPathParts: [child.name, ...descendantResult.parsedPathParts] } } } const optionalMatchAllToken = ptrParse(path).find( (ptrToken) => typeof ptrToken === 'object' && ptrToken.modifier === '*', ) as PtrKey if (optionalMatchAllToken) { return { parsedPathParts: [child.name], additionalQuery: { [optionalMatchAllToken.name]: [] }, firstMatchType: MATCH_TYPE.MATCHALL, } } } return undefined } /** * Recursively parse paths to identify the matching file path, and extract the parameters * * We must do this taking care of the priorities: * 1. static match among the current route branch children * 2. static match among among the descendants of a child that is path-ignored * 3. dynamic match among the current route branch children * 4. dynamic match among among the descendants of a child that is path-ignored * 5. match all match among the current route branch children * 6. match all match among among the descendants of a child that is path-ignored */ export const parsePathParts = ({ locale, pathParts = [], routeBranch, }: { locale: string /** * The path parts to parse using the routeBranch children * A path part can be a bare name or a dynamic value */ pathParts?: string[] routeBranch: TRouteBranch }): TParsedPathParts | undefined => { const { children } = routeBranch // If there is no path parts left to parse if (pathParts.length === 0) { if (!children?.length) { // The current routeBranch have no children, this is a match return { parsedPathParts: [], additionalQuery: {}, firstMatchType: MATCH_TYPE.STATIC } } return getEndFilepathParts(routeBranch, locale) } const currentPathPart = pathParts[0] const nextPathParts = pathParts.slice(1) // Ignore empty path parts if (!currentPathPart) { return parsePathParts({ locale, pathParts: nextPathParts, routeBranch }) } // We are looking for the route matching the current path part among ther routeBranch children // It can be a static path part, or a dynamic path part (`[slug]` or `[...path]` for exemple) if (!children?.length) { // No match possible return undefined } // 1. Lets first look for static matches among the current children /** We will store here candidates that does not match statically */ const delayedCandidates: { candidate: TRouteBranch; isPathIgnored?: boolean }[] = [] let matchAllCandidate: TRouteBranch | undefined = undefined for (const candidate of children) { const path = candidate.paths[locale] || candidate.paths.default // Does the candidate statically match? if (path === currentPathPart) { // It does! But does its children match too? const childrenParsedPathParts = parsePathParts({ locale, pathParts: nextPathParts, routeBranch: candidate }) if (childrenParsedPathParts) { // They do! Let's return the result immediately return { ...childrenParsedPathParts, // Keep additionalQuery parsedPathParts: [candidate.name, ...childrenParsedPathParts.parsedPathParts], firstMatchType: MATCH_TYPE.STATIC, } } } const isAnyDynamicPathPattern = anyDynamicPathPatternPartRegex.test(path) const isIgnorePathPattern = ignoreSegmentPathRegex.test(path) // Does the candidate path is static even if it does not match? if (!isAnyDynamicPathPattern && !isIgnorePathPattern) { // This candidate is static and does not match the currentPathPart: no need to check further continue } // So this candidate is not static // Let's classify it to use it later and first look for a static match (priority): // is it path-ignored, dynamic, or match-all? if (isIgnorePathPattern) { // It is path-ignored, let's unshift (hight priority) it among the delayedCandidates delayedCandidates.unshift({ candidate, isPathIgnored: true }) continue } else if ( ptrParse(path).some((ptrToken) => typeof ptrToken === 'object' && ['+', '*'].includes(ptrToken.modifier)) ) { // It is a match-all path: let's store it alone (there can be only one) matchAllCandidate = candidate } else { // It is a dynamic path, let's push (low priority) it among the delayedCandidates delayedCandidates.push({ candidate, isPathIgnored: false }) } } // Now we know that there is no static match among these children // 2. & 3.Let's take care of these delayed candidates /** * We will store here the non static matches from path-ignored candidates * to handle them if there is not dynamic match among the current children */ const pathIgnoredResults: Partial> = {} for (const { candidate, isPathIgnored } of delayedCandidates) { if (isPathIgnored) { // 2. Let's look among the descendants of this path-ignored candidate const rawResult = parsePathParts({ locale, routeBranch: candidate, pathParts, }) if (rawResult) { // Found a match const result = { ...rawResult, // Keep the additionalQuery and firstMatchType parsedPathParts: [candidate.name, ...rawResult.parsedPathParts], } if (rawResult.firstMatchType === MATCH_TYPE.STATIC) { // Static match: that's it, let's take this one return result } else { // Let's store it for now, we will use it if we don't find something better pathIgnoredResults[rawResult.firstMatchType] = result } } } else { // 3. If we are here, it means that we did not find any static match, even among path-ignored candidates descendants, // because we sorted the candidates in the delayedCandidates array: first the path-ignored candidates, then the dynamic ones. const path = candidate.paths[locale] || candidate.paths.default const match = ptrMatch(path)(currentPathPart) if (match) { // It matches! But does its children match too? const childrenParsedPathParts = parsePathParts({ locale, pathParts: nextPathParts, routeBranch: candidate }) if (childrenParsedPathParts) { // They do! Let's return the result immediately return { parsedPathParts: [candidate.name, ...childrenParsedPathParts.parsedPathParts], additionalQuery: { ...childrenParsedPathParts.additionalQuery, ...match.params }, firstMatchType: MATCH_TYPE.DYNAMIC, } } } } } // 4. Do we have a dynamic match stored in the pathIgnoredMatches? if (pathIgnoredResults[MATCH_TYPE.DYNAMIC]) { // Yes, let's return it return pathIgnoredResults[MATCH_TYPE.DYNAMIC] } // 5. Do we have a matchAllCandidate stored? if (matchAllCandidate) { // Yes. const path = matchAllCandidate.paths[locale] || matchAllCandidate.paths.default const match = ptrMatch('/' + path)('/' + pathParts.join('/')) if (match) { // It matches! And it does not have children (or should not). return { parsedPathParts: [matchAllCandidate.name], additionalQuery: match.params, firstMatchType: MATCH_TYPE.MATCHALL, } } } // 6. Do we have a match all match stored in the pathIgnoredMatches? if (pathIgnoredResults[MATCH_TYPE.DYNAMIC]) { // Yes, let's return it return pathIgnoredResults[MATCH_TYPE.DYNAMIC] } // Still there? No match then. return undefined } /** * Parse a translated url to get the corresponding file url (UrlObject) * * Ex: `urlToFileUrl('/fr/mon/url/de/france', 'fr')` => `{ pathname: '/my/url/from/[country], query: { country: 'france' } }` * * @param url The url to parse: can be a string, an URL or an UrlObject * @param locale (optional) The locale corresponding to the url translation. * If omitted, urlToFileUrl will look for the locale in the first path part. * If it does not match a locale, urlToFileUrl will use the default locale. * * @returns The file path based, Next.js format, url in UrlObject format * if the url successfully matched a file path, and undefined otherwise */ export const urlToFileUrl = (url: string | URL | UrlObject, locale?: string) => { const { routesTree, defaultLocale, locales } = getNtrData() const { pathname, query, hash } = urlToUrlObject(url) if (pathname && anyDynamicFilepathPartsRegex.exec(pathname)) { // Not sure if we should return undefined instead. Or throw? return { pathname, query, hash } } const result = parsePathParts({ locale: locale || defaultLocale || locales[0], routeBranch: routesTree, pathParts: removeLangPrefix(pathname || '/', true, locale), }) if (result) { const { parsedPathParts, additionalQuery } = result return { pathname: `/${parsedPathParts.join('/')}`, query: { ...query, ...additionalQuery }, ...(hash && { hash }), } } return undefined }