UNPKG

@tanstack/router-generator

Version:

Modern and scalable routing for React applications

634 lines (557 loc) 17.3 kB
import * as fsp from 'node:fs/promises' import path from 'node:path' import * as prettier from 'prettier' import { rootPathId } from './filesystem/physical/rootPathId' import type { Config } from './config' import type { ImportDeclaration, RouteNode } from './types' export function multiSortBy<T>( arr: Array<T>, accessors: Array<(item: T) => any> = [(d) => d], ): Array<T> { return arr .map((d, i) => [d, i] as const) .sort(([a, ai], [b, bi]) => { for (const accessor of accessors) { const ao = accessor(a) const bo = accessor(b) if (typeof ao === 'undefined') { if (typeof bo === 'undefined') { continue } return 1 } if (ao === bo) { continue } return ao > bo ? 1 : -1 } return ai - bi }) .map(([d]) => d) } export function cleanPath(path: string) { // remove double slashes return path.replace(/\/{2,}/g, '/') } export function trimPathLeft(path: string) { return path === '/' ? path : path.replace(/^\/{1,}/, '') } export function removeLeadingSlash(path: string): string { return path.replace(/^\//, '') } export function removeTrailingSlash(s: string) { return s.replace(/\/$/, '') } export function determineInitialRoutePath(routePath: string) { const DISALLOWED_ESCAPE_CHARS = new Set([ '/', '\\', '?', '#', ':', '*', '<', '>', '|', '!', '$', '%', ]) const parts = routePath.split(/(?<!\[)\.(?!\])/g) // Escape any characters that in square brackets const escapedParts = parts.map((part) => { // Check if any disallowed characters are used in brackets const BRACKET_CONTENT_RE = /\[(.*?)\]/g let match while ((match = BRACKET_CONTENT_RE.exec(part)) !== null) { const character = match[1] if (character === undefined) continue if (DISALLOWED_ESCAPE_CHARS.has(character)) { console.error( `Error: Disallowed character "${character}" found in square brackets in route path "${routePath}".\nYou cannot use any of the following characters in square brackets: ${Array.from( DISALLOWED_ESCAPE_CHARS, ).join(', ')}\nPlease remove and/or replace them.`, ) process.exit(1) } } // Since this split segment is safe at this point, we can // remove the brackets and replace them with the content inside return part.replace(/\[(.)\]/g, '$1') }) // If the syntax for prefix/suffix is different, from the path // matching internals of router-core, we'd perform those changes here // on the `escapedParts` array before it is joined back together in // `final` const final = cleanPath(`/${escapedParts.join('/')}`) || '' return final } export function replaceBackslash(s: string) { return s.replaceAll(/\\/gi, '/') } export function routePathToVariable(routePath: string): string { const toVariableSafeChar = (char: string): string => { if (/[a-zA-Z0-9_]/.test(char)) { return char // Keep alphanumeric characters and underscores as is } // Replace special characters with meaningful text equivalents switch (char) { case '.': return 'Dot' case '-': return 'Dash' case '@': return 'At' case '(': return '' // Removed since route groups use parentheses case ')': return '' // Removed since route groups use parentheses case ' ': return '' // Remove spaces default: return `Char${char.charCodeAt(0)}` // For any other characters } } return ( removeUnderscores(routePath) ?.replace(/\/\$\//g, '/splat/') .replace(/\$$/g, 'splat') .replace(/\$\{\$\}/g, 'splat') .replace(/\$/g, '') .split(/[/-]/g) .map((d, i) => (i > 0 ? capitalize(d) : d)) .join('') .split('') .map(toVariableSafeChar) .join('') // .replace(/([^a-zA-Z0-9]|[.])/gm, '') .replace(/^(\d)/g, 'R$1') ?? '' ) } export function removeUnderscores(s?: string) { return s?.replaceAll(/(^_|_$)/gi, '').replaceAll(/(\/_|_\/)/gi, '/') } export function capitalize(s: string) { if (typeof s !== 'string') return '' return s.charAt(0).toUpperCase() + s.slice(1) } export function removeExt(d: string, keepExtension: boolean = false) { return keepExtension ? d : d.substring(0, d.lastIndexOf('.')) || d } /** * This function writes to a file if the content is different. * * @param filepath The path to the file * @param content Original content * @param incomingContent New content * @param callbacks Callbacks to run before and after writing * @returns Whether the file was written */ export async function writeIfDifferent( filepath: string, content: string, incomingContent: string, callbacks?: { beforeWrite?: () => void; afterWrite?: () => void }, ): Promise<boolean> { if (content !== incomingContent) { callbacks?.beforeWrite?.() await fsp.writeFile(filepath, incomingContent) callbacks?.afterWrite?.() return true } return false } /** * This function formats the source code using the default formatter (Prettier). * * @param source The content to format * @param config The configuration object * @returns The formatted content */ export async function format( source: string, config: { quoteStyle: 'single' | 'double' semicolons: boolean }, ): Promise<string> { const prettierOptions: prettier.Config = { semi: config.semicolons, singleQuote: config.quoteStyle === 'single', parser: 'typescript', } return prettier.format(source, prettierOptions) } /** * This function resets the regex index to 0 so that it can be reused * without having to create a new regex object or worry about the last * state when using the global flag. * * @param regex The regex object to reset * @returns */ export function resetRegex(regex: RegExp) { regex.lastIndex = 0 return } /** * This function checks if a file exists. * * @param file The path to the file * @returns Whether the file exists */ export async function checkFileExists(file: string) { try { await fsp.access(file, fsp.constants.F_OK) return true } catch { return false } } const possiblyNestedRouteGroupPatternRegex = /\([^/]+\)\/?/g export function removeGroups(s: string) { return s.replace(possiblyNestedRouteGroupPatternRegex, '') } /** * Removes all segments from a given path that start with an underscore ('_'). * * @param {string} routePath - The path from which to remove segments. Defaults to '/'. * @returns {string} The path with all underscore-prefixed segments removed. * @example * removeLayoutSegments('/workspace/_auth/foo') // '/workspace/foo' */ export function removeLayoutSegments(routePath: string = '/'): string { const segments = routePath.split('/') const newSegments = segments.filter((segment) => !segment.startsWith('_')) return newSegments.join('/') } /** * The `node.path` is used as the `id` in the route definition. * This function checks if the given node has a parent and if so, it determines the correct path for the given node. * @param node - The node to determine the path for. * @returns The correct path for the given node. */ export function determineNodePath(node: RouteNode) { return (node.path = node.parent ? node.routePath?.replace(node.parent.routePath ?? '', '') || '/' : node.routePath) } /** * Removes the last segment from a given path. Segments are considered to be separated by a '/'. * * @param {string} routePath - The path from which to remove the last segment. Defaults to '/'. * @returns {string} The path with the last segment removed. * @example * removeLastSegmentFromPath('/workspace/_auth/foo') // '/workspace/_auth' */ export function removeLastSegmentFromPath(routePath: string = '/'): string { const segments = routePath.split('/') segments.pop() // Remove the last segment return segments.join('/') } export function hasParentRoute( routes: Array<RouteNode>, node: RouteNode, routePathToCheck: string | undefined, ): RouteNode | null { if (!routePathToCheck || routePathToCheck === '/') { return null } const sortedNodes = multiSortBy(routes, [ (d) => d.routePath!.length * -1, (d) => d.variableName, ]).filter((d) => d.routePath !== `/${rootPathId}`) for (const route of sortedNodes) { if (route.routePath === '/') continue if ( routePathToCheck.startsWith(`${route.routePath}/`) && route.routePath !== routePathToCheck ) { return route } } const segments = routePathToCheck.split('/') segments.pop() // Remove the last segment const parentRoutePath = segments.join('/') return hasParentRoute(routes, node, parentRoutePath) } /** * Gets the final variable name for a route */ export const getResolvedRouteNodeVariableName = ( routeNode: RouteNode, variableNameSuffix: string, ): string => { return routeNode.children?.length ? `${routeNode.variableName}${variableNameSuffix}WithChildren` : `${routeNode.variableName}${variableNameSuffix}` } /** * Checks if a given RouteNode is valid for augmenting it with typing based on conditions. * Also asserts that the RouteNode is defined. * * @param routeNode - The RouteNode to check. * @returns A boolean indicating whether the RouteNode is defined. */ export function isRouteNodeValidForAugmentation( routeNode?: RouteNode, ): routeNode is RouteNode { if (!routeNode || routeNode.isVirtual) { return false } return true } /** * Infers the path for use by TS */ export const inferPath = (routeNode: RouteNode): string => { return routeNode.cleanedPath === '/' ? routeNode.cleanedPath : (routeNode.cleanedPath?.replace(/\/$/, '') ?? '') } /** * Infers the full path for use by TS */ export const inferFullPath = (routeNode: RouteNode): string => { const fullPath = removeGroups( removeUnderscores(removeLayoutSegments(routeNode.routePath)) ?? '', ) return routeNode.cleanedPath === '/' ? fullPath : fullPath.replace(/\/$/, '') } /** * Creates a map from fullPath to routeNode */ export const createRouteNodesByFullPath = ( routeNodes: Array<RouteNode>, ): Map<string, RouteNode> => { return new Map( routeNodes.map((routeNode) => [inferFullPath(routeNode), routeNode]), ) } /** * Create a map from 'to' to a routeNode */ export const createRouteNodesByTo = ( routeNodes: Array<RouteNode>, ): Map<string, RouteNode> => { return new Map( dedupeBranchesAndIndexRoutes(routeNodes).map((routeNode) => [ inferTo(routeNode), routeNode, ]), ) } /** * Create a map from 'id' to a routeNode */ export const createRouteNodesById = ( routeNodes: Array<RouteNode>, ): Map<string, RouteNode> => { return new Map( routeNodes.map((routeNode) => { const id = routeNode.routePath ?? '' return [id, routeNode] }), ) } /** * Infers to path */ export const inferTo = (routeNode: RouteNode): string => { const fullPath = inferFullPath(routeNode) if (fullPath === '/') return fullPath return fullPath.replace(/\/$/, '') } /** * Dedupes branches and index routes */ export const dedupeBranchesAndIndexRoutes = ( routes: Array<RouteNode>, ): Array<RouteNode> => { return routes.filter((route) => { if (route.children?.find((child) => child.cleanedPath === '/')) return false return true }) } function checkUnique<TElement>(routes: Array<TElement>, key: keyof TElement) { // Check no two routes have the same `key` // if they do, throw an error with the conflicting filePaths const keys = routes.map((d) => d[key]) const uniqueKeys = new Set(keys) if (keys.length !== uniqueKeys.size) { const duplicateKeys = keys.filter((d, i) => keys.indexOf(d) !== i) const conflictingFiles = routes.filter((d) => duplicateKeys.includes(d[key]), ) return conflictingFiles } return undefined } export function checkRouteFullPathUniqueness( _routes: Array<RouteNode>, config: Config, ) { const routes = _routes.map((d) => { const inferredFullPath = inferFullPath(d) return { ...d, inferredFullPath } }) const conflictingFiles = checkUnique(routes, 'inferredFullPath') if (conflictingFiles !== undefined) { const errorMessage = `Conflicting configuration paths were found for the following route${conflictingFiles.length > 1 ? 's' : ''}: ${conflictingFiles .map((p) => `"${p.inferredFullPath}"`) .join(', ')}. Please ensure each Route has a unique full path. Conflicting files: \n ${conflictingFiles.map((d) => path.resolve(config.routesDirectory, d.filePath)).join('\n ')}\n` throw new Error(errorMessage) } } export function buildRouteTreeConfig( nodes: Array<RouteNode>, exportName: string, disableTypes: boolean, depth = 1, ): Array<string> { const children = nodes .filter((n) => n.exports?.includes(exportName)) .map((node) => { if (node._fsRouteType === '__root') { return } if (node._fsRouteType === 'pathless_layout' && !node.children?.length) { return } const route = `${node.variableName}` if (node.children?.length) { const childConfigs = buildRouteTreeConfig( node.children, exportName, disableTypes, depth + 1, ) const childrenDeclaration = disableTypes ? '' : `interface ${route}${exportName}Children { ${node.children .filter((n) => n.exports?.includes(exportName)) .map( (child) => `${child.variableName}${exportName}: typeof ${getResolvedRouteNodeVariableName(child, exportName)}`, ) .join(',')} }` const children = `const ${route}${exportName}Children${disableTypes ? '' : `: ${route}${exportName}Children`} = { ${node.children .filter((n) => n.exports?.includes(exportName)) .map( (child) => `${child.variableName}${exportName}: ${getResolvedRouteNodeVariableName(child, exportName)}`, ) .join(',')} }` const routeWithChildren = `const ${route}${exportName}WithChildren = ${route}${exportName}._addFileChildren(${route}${exportName}Children)` return [ childConfigs.join('\n'), childrenDeclaration, children, routeWithChildren, ].join('\n\n') } return undefined }) return children.filter((x) => x !== undefined) } export function buildImportString( importDeclaration: ImportDeclaration, ): string { const { source, specifiers, importKind } = importDeclaration return specifiers.length ? `import ${importKind === 'type' ? 'type ' : ''}{ ${specifiers.map((s) => (s.local ? `${s.imported} as ${s.local}` : s.imported)).join(', ')} } from '${source}'` : '' } export function lowerCaseFirstChar(value: string) { if (!value[0]) { return value } return value[0].toLowerCase() + value.slice(1) } export function mergeImportDeclarations( imports: Array<ImportDeclaration>, ): Array<ImportDeclaration> { const merged: Record<string, ImportDeclaration> = {} for (const imp of imports) { const key = `${imp.source}-${imp.importKind}` if (!merged[key]) { merged[key] = { ...imp, specifiers: [] } } for (const specifier of imp.specifiers) { // check if the specifier already exists in the merged import if ( !merged[key].specifiers.some( (existing) => existing.imported === specifier.imported && existing.local === specifier.local, ) ) { merged[key].specifiers.push(specifier) } } } return Object.values(merged) } export function hasChildWithExport( node: RouteNode, exportName: string, ): boolean { return ( node.children?.some((child) => hasChildWithExport(child, exportName)) ?? false ) } export const findParent = ( node: RouteNode | undefined, exportName: string, ): string => { if (!node) { return `root${exportName}Import` } if (node.parent) { if (node.parent.exports?.includes(exportName)) { if (node.isVirtualParentRequired) { return `${node.parent.variableName}${exportName}` } else { return `${node.parent.variableName}${exportName}` } } } return findParent(node.parent, exportName) } export function buildFileRoutesByPathInterface(opts: { routeNodes: Array<RouteNode> module: string interfaceName: string exportName: string }): string { return `declare module '${opts.module}' { interface ${opts.interfaceName} { ${opts.routeNodes .map((routeNode) => { const filePathId = routeNode.routePath let preloaderRoute = '' if (routeNode.exports?.includes(opts.exportName)) { preloaderRoute = `typeof ${routeNode.variableName}${opts.exportName}Import` } else { preloaderRoute = 'unknown' } const parent = findParent(routeNode, opts.exportName) return `'${filePathId}': { id: '${filePathId}' path: '${inferPath(routeNode)}' fullPath: '${inferFullPath(routeNode)}' preLoaderRoute: ${preloaderRoute} parentRoute: typeof ${parent} }` }) .join('\n')} } }` }