next-translate-routes-multi-domain
Version:
Flexible and translated routes for Next.js without custom server
269 lines (237 loc) • 10.2 kB
text/typescript
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<Record<MATCH_TYPE, TParsedPathParts>> = {}
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<ParsedUrlQuery>(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<ParsedUrlQuery>('/' + 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
}