next-translate-routes-multi-domain
Version:
Flexible and translated routes for Next.js without custom server
221 lines (189 loc) • 7.63 kB
text/typescript
import { normalizePathTrailingSlash } from 'next/dist/client/normalize-trailing-slash'
import type { Key } from 'path-to-regexp'
import { compile as compilePath, parse as parsePath } from 'path-to-regexp'
import { parse as parseQuery, stringify as stringifyQuery, ParsedUrlQuery } from 'querystring'
import { format as formatUrl, parse as parseUrl, UrlObject } from 'url'
import { getDynamicPathPartKey, getSpreadFilepathPartKey, ignoreSegmentPathRegex } from '../shared/regex'
import type { TRouteBranch, Url } from '../types'
import { getNtrData } from './ntrData'
import { removeLangPrefix } from './removeLangPrefix'
type Options<F extends 'string' | 'object' = 'string' | 'object'> = {
format?: F
withoutLangPrefix?: boolean
}
/** Get children + (grand)children of children whose path must be ignord (path === '.' or path === `.(${pathRegex})`) */
const getAllCandidates = (lang: string, children?: TRouteBranch[]): TRouteBranch[] =>
children
? children.reduce((acc, child) => {
const path = child.paths[lang] || child.paths.default
return [...acc, ...(path === '' ? getAllCandidates(lang, child.children) : [child])]
}, [] as TRouteBranch[])
: []
/**
* Recursively translate paths from file path, and extract parameters
*/
const translatePathParts = ({
locale,
pathParts,
routeBranch,
query,
}: {
locale: string
/** Can be a bare name or a dynamic value */
pathParts: string[]
routeBranch: TRouteBranch
query?: ParsedUrlQuery
}): { augmentedQuery?: ParsedUrlQuery; translatedPathParts: string[] } => {
const { children } = routeBranch
if (!Array.isArray(pathParts)) {
throw new Error('Wrong pathParts argument in translatePathParts')
}
if (pathParts.length === 0) {
return { translatedPathParts: [], augmentedQuery: query }
}
const pathPart = pathParts[0]
const nextPathParts = pathParts.slice(1)
if (!pathPart) {
return translatePathParts({ locale, pathParts: nextPathParts, routeBranch, query })
}
const candidates = getAllCandidates(locale, children).filter((child) =>
pathParts.length === 1 // Last path part
? !child.children ||
child.children.some((grandChild) => grandChild.name === 'index' || /\[\[\.{0,3}\w+\]\]/.exec(grandChild.name))
: !!child.children || /\[\[?\.{3}\w+\]?\]/.exec(child.name),
)
let currentQuery = query
/**
* If defined: pathPart is a route segment name that should be translated.
* If dynamic, the value should already be contained in the query.
*/
let childRouteBranch = candidates.find(({ name }) => pathPart === name)
if (!childRouteBranch) {
// If not defined: pathPart is either a dynamic value either a wrong path.
childRouteBranch = candidates.find((candidate) => getDynamicPathPartKey(candidate.name))
if (childRouteBranch) {
// Single dynamic route segment value => store it in the query
currentQuery = {
...currentQuery,
[childRouteBranch.name.replace(/\[|\]|\./g, '')]: pathPart,
}
} else {
childRouteBranch = candidates.find((candidate) => getSpreadFilepathPartKey(candidate.name))
if (childRouteBranch) {
// Catch all route => store it in the query, then return the current data.
currentQuery = {
...currentQuery,
[childRouteBranch.name.replace(/\[|\]|\./g, '')]: pathParts,
}
return {
translatedPathParts: [childRouteBranch.paths[locale] || childRouteBranch.paths.default],
augmentedQuery: currentQuery,
}
}
// No route match => return the remaining path as is
return { translatedPathParts: pathParts, augmentedQuery: query }
}
}
// Get the descendants translated path parts and query values
const { augmentedQuery, translatedPathParts: nextTranslatedPathsParts } = childRouteBranch?.children
? translatePathParts({ locale, pathParts: nextPathParts, routeBranch: childRouteBranch, query: currentQuery })
: { augmentedQuery: currentQuery, translatedPathParts: [] }
const translatedPathPart = childRouteBranch.paths[locale] || childRouteBranch.paths.default
return {
translatedPathParts: [
...(ignoreSegmentPathRegex.test(translatedPathPart) ? [] : [translatedPathPart]),
...(nextTranslatedPathsParts || []),
],
augmentedQuery,
}
}
/**
* Translate path into locale
*
* @param url string url or UrlObject
* @param locale string
* @param options (optional)
* @param options.format `'string'` or `'object'`
* @return string if `options.format === 'string'`,
* UrlObject if `options.format === 'object'`,
* same type as url if options.format is not defined
*/
export function translatePath<U extends string | UrlObject, F extends 'string' | 'object'>(
url: U,
locale: string,
options?: Options<F>,
): 'string' | 'object' extends F
? U extends string
? string
: U extends UrlObject
? UrlObject
: Url
: F extends 'string'
? string
: UrlObject
export function translatePath(url: Url, locale: string, { format }: Options = {}): Url {
const { routesTree } = getNtrData()
const returnFormat = format || typeof url
const urlObject = typeof url === 'object' ? (url as UrlObject) : parseUrl(url, true)
const { pathname, query, hash } = urlObject
if (!pathname || !locale) {
return returnFormat === 'object' ? url : formatUrl(url)
}
const pathParts = removeLangPrefix(pathname, true)
const { translatedPathParts, augmentedQuery = {} } = translatePathParts({
locale,
pathParts,
query: parseQuery(typeof query === 'string' ? query : stringifyQuery(query || {})),
routeBranch: routesTree,
})
const path = translatedPathParts.join('/')
const compiledPath = compilePath(path, { validate: false })(augmentedQuery)
const paramNames = (parsePath(path).filter((token) => typeof token === 'object') as Key[]).map((token) => token.name)
const remainingQuery = Object.keys(augmentedQuery).reduce(
(acc, key) => ({
...acc,
...(paramNames.includes(key)
? {}
: { [key]: (typeof query === 'object' && query?.[key]) || augmentedQuery[key] }),
}),
{},
)
const translatedPathname = `${routesTree.paths[locale] ? `/${routesTree.paths[locale]}` : ''}/${compiledPath}`
const translatedUrlObject = {
...urlObject,
hash,
pathname: translatedPathname,
query: remainingQuery,
}
return returnFormat === 'object' ? translatedUrlObject : formatUrl(translatedUrlObject)
}
export type TTranslateUrl = typeof translatePath
/**
* Translate url into locale
*
* Will soon be deprecated in favor of fileUrlToUrl and urlToFileUrl
*
* @param url string url or UrlObject
* @param locale string
* @param options (optional)
* @param options.format `'string'` or `'object'`
* @return string if `options.format === 'string'`,
* UrlObject if `options.format === 'object'`,
* same type as url if options.format is not defined
*/
export const translateUrl: TTranslateUrl = ((url, locale, options) => {
const { defaultLocale } = getNtrData()
// Handle external urls
const parsedUrl: UrlObject = typeof url === 'string' ? parseUrl(url) : url
if (parsedUrl.host) {
if (typeof window === 'undefined' || parsedUrl.host !== parseUrl(window.location.href).host) {
return url
}
}
const translatedPath = translatePath(url, locale, options)
if (typeof translatedPath === 'object') {
return translatedPath
}
const prefix = locale === defaultLocale || options?.withoutLangPrefix ? '' : `/${locale}`
return normalizePathTrailingSlash(prefix + translatedPath)
}) as typeof translatePath