diff --git a/.eslintrc b/.eslintrc index 55ecadef..382fcff6 100644 --- a/.eslintrc +++ b/.eslintrc @@ -78,7 +78,7 @@ "import/no-named-as-default-member": "error", "import/no-unused-modules": "error", "import/order": [ - "error", + "off", { "groups": [ "builtin", @@ -98,6 +98,6 @@ "no-redeclare": "error", "no-shadow": "error", "no-undef": "error", - "sort-imports": "error", + "sort-imports": "off", }, } diff --git a/.gitignore b/.gitignore index 87dcb2fe..dc5a252b 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,8 @@ node_modules/ dist/ .turbo/ +*/**/routeTree.gen.ts + pnpm-lock.yaml examples/playground/ diff --git a/package.json b/package.json index 6529ea14..f0dcf987 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,8 @@ "packageManager": "pnpm@8.12.1", "scripts": { "lint": "turbo lint", - "format": "turbo format" + "format": "turbo format", + "test": "turbo test" }, "keywords": [], "type": "module", diff --git a/packages/router-generator/README.md b/packages/router-generator/README.md new file mode 100644 index 00000000..b9b54407 --- /dev/null +++ b/packages/router-generator/README.md @@ -0,0 +1,4 @@ +# Router generator + +This package handle the creation of the routes. +Basically collects and manages them in a single entry point file. diff --git a/packages/router-generator/package.json b/packages/router-generator/package.json index 447ef9a7..bab665dc 100644 --- a/packages/router-generator/package.json +++ b/packages/router-generator/package.json @@ -2,12 +2,11 @@ "name": "router-generator", "version": "0.1.0", "description": "", - "main": "index.js", "scripts": { "build": "vite build", "lint": "eslint --ext .ts,.tsx ./src -c ../../.eslintrc", "format": "prettier -u --write '**/*'", - "test": "vitest --typecheck --watch=false", + "test": "vitest --watch=false", "types": "tsc --noEmit" }, "keywords": [], @@ -31,6 +30,7 @@ ], "dependencies": { "@tanstack/config": "^0.7.0", + "prettier": "^3.2.4", "vite": "^5.2.10", "vitest": "^1.5.2" }, diff --git a/packages/router-generator/src/_index.ts b/packages/router-generator/src/_index.ts new file mode 100644 index 00000000..b735b750 --- /dev/null +++ b/packages/router-generator/src/_index.ts @@ -0,0 +1,532 @@ +/** + * Overview: + * + * src/routes = Routes entry point + * src/routes/layout.tsx = Shared layout + * src/routes/index.tsx = xyz.com + * src/routes/about.tsx = xyz.com/about + * src/routes/about-loading.tsx = xyz.com/about - while loading data + * src/routes/about/index.tsx = xyz.com/about + * src/routes/posts/[slug].tsx = xyz.com/posts/my-lovely-post + * src/routes/posts/[...params].tsx = xyz.com/posts/my-lovely-post/commend-id-304 + * src/routes/404.tsx = Not found + * + * public/ = Public files + * + * All the routes are lazy loaded! + */ + +import * as fs from 'fs' +import * as fsp from 'fs/promises' +import { format } from 'prettier' +import path from 'path' + +const ROUTES_FOLDER = './src/routes/' +const ROOT_PATH_ID = '__root' +const GENERATED_ROUTE_TREE = './src/routeTree.gen.ts' + +let latestTask = 0 +const routeGroupPatternRegex = /\(.+\)/g + +interface RouteNode { + filePath: string + fullPath: string + routePath: string + path?: string + cleanedPath?: string + isLayout?: boolean + isLoader: boolean + children?: RouteNode[] + parent?: RouteNode + variableName?: string +} + +let isFirst = false +let skipMessage = false + +export function removeExt(d: string, keepExtension: boolean = false): string { + return keepExtension ? d : d.substring(0, d.lastIndexOf('.')) || d +} + +function replaceBackslash(s: string): string { + return s.replaceAll(/\\/gi, '/') +} + +export function cleanPath(pathToClean: string): string { + // remove double slashes + return pathToClean.replace(/\/{2,}/g, '/') +} + +function removeUnderscores(s?: string): string | undefined { + return s?.replaceAll(/(^_|_$)/gi, '').replaceAll(/(\/_|_\/)/gi, '/') +} + +function routePathToVariable(routePath: string): string { + return ( + removeUnderscores(routePath) + ?.replace(/\/\$\//g, '/splat/') + .replace(/\$$/g, 'splat') + .replace(/\$/g, '') + .split(/[/-]/g) + .map((d, i) => (i > 0 ? capitalize(d) : d)) + .join('') + .replace(/([^a-zA-Z0-9]|[.])/gm, '') + .replace(/^(\d)/g, 'R$1') ?? '' + ) +} + +function capitalize(s: string): string { + if (typeof s !== 'string') return '' + return s.charAt(0).toUpperCase() + s.slice(1) +} + +async function getRouteNodes(config = defaultConfig): Promise { + const routeNodes: RouteNode[] = [] + + async function recurse(dir: string): Promise { + const fullDir = path.resolve(config.folderName, dir) + const dirList = await fsp.readdir(fullDir, { withFileTypes: true }) + + await Promise.all( + dirList.map(async (dirent) => { + const fullPath = path.join(fullDir, dirent.name) + const relativePath = path.join(dir, dirent.name) + + if (dirent.isDirectory()) { + await recurse(relativePath) + } else if (fullPath.match(/\.(tsx|ts|jsx|js)$/)) { + const filePath = replaceBackslash(path.join(dir, dirent.name)) + const filePathNoExt = removeExt(filePath) + let routePath = + cleanPath(`/${filePathNoExt.split('.').join('/')}`) || '' + + const variableName = routePathToVariable(routePath) + + // Remove the index from the route path and + // if the route path is empty, use `/' + + const isLoader = routePath.includes('-loading') + + if (routePath === 'index') { + routePath = '/' + } + + routePath = routePath.replace(/\/index$/, '/') || '/' + + routeNodes.push({ + filePath, + fullPath, + routePath, + isLoader, + variableName, + }) + } + }), + ) + } + + await recurse('./') + + return routeNodes +} + +export function multiSortBy( + arr: T[], + accessors: ((item: T) => any)[] = [(d): any => d], +): 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) +} + +/** + * @deprecated + */ +interface RouteSubNode { + component?: RouteNode + errorComponent?: RouteNode + pendingComponent?: RouteNode + loader?: RouteNode + lazy?: RouteNode +} + +export function hasParentRoute( + routes: RouteNode[], + node: RouteNode, + routePathToCheck: string | undefined, +): RouteNode | null { + if (!routePathToCheck || routePathToCheck === '/') { + return null + } + + const sortedNodes = multiSortBy(routes, [ + (d): number => d.routePath.length * -1, + (d): string | undefined => d.variableName, + ]).filter((d) => d.routePath !== `/${ROOT_PATH_ID}`) + + 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) +} + +interface Config { + folderName: string + generatedRouteTree: string +} + +const defaultConfig: Config = { + folderName: ROUTES_FOLDER, + generatedRouteTree: GENERATED_ROUTE_TREE, +} + +export async function routeGenerator(config = defaultConfig): Promise { + if (!isFirst) { + console.log('Generating routes...') + isFirst = true + } else if (skipMessage) { + skipMessage = false + } else { + console.log('Regenerating routes') + } + + const checkLatest = (): boolean => { + if (latestTask !== taskId) { + skipMessage = true + return false + } + + return true + } + + const taskId = latestTask + 1 + latestTask = taskId + + const start = Date.now() + const beforeRouteNodes = await getRouteNodes(config) + + const preRouteNodes = multiSortBy(beforeRouteNodes, [ + (d): number => (d.routePath === '/' ? -1 : 1), + (d): number => d.routePath.split('/').length, + (d): number => (d.filePath.match(/[./]index[.]/) ? 1 : -1), + (d): number => + d.filePath.match( + /[./](component|errorComponent|pendingComponent|loader|lazy)[.]/, + ) + ? 1 + : -1, + (d): number => (d.filePath.match(/[./]route[.]/) ? -1 : 1), + (d): number => (d.routePath.endsWith('/') ? -1 : 1), + (d): string => d.routePath, + ]).filter((d) => ![`/${ROOT_PATH_ID}`].includes(d.routePath || '')) + + const routeTree: RouteNode[] = [] + const routePiecesByPath: Record = {} + + // Loop over the flat list of routeNodes and + // build up a tree based on the routeNodes' routePath + const routeNodes: RouteNode[] = [] + + const handleNode = async (node: RouteNode): Promise => { + const parentRoute = hasParentRoute(routeNodes, node, node.routePath) + + if (parentRoute) node.parent = parentRoute + + node.path = determineNodePath(node) + + const trimmedPath = trimPathLeft(node.path ?? '') + const split = trimmedPath.split('/') + const first = split[0] ?? trimmedPath + const lastRouteSegment = split[split.length - 1] ?? trimmedPath + + // TODO: check these two + node.isNonPath = lastRouteSegment.startsWith('_') + node.isNonLayout = first.endsWith('_') + + node.cleanedPath = removeGroups( + removeUnderscores(removeLayoutSegments(node.path)) ?? '', + ) + + const routeCode = fs.readFileSync(node.fullPath, 'utf-8') + + const escapedRoutePath = removeTrailingUnderscores( + node.routePath.replaceAll('$', '$$') ?? '', + ) + + let replaced = routeCode + + if (!routeCode) { + replaced = [ + `import { createFileRoute } from '@tanstack/react-router'`, + `export const Route = createFileRoute('${escapedRoutePath}')({ + component: () =>
Hello ${escapedRoutePath}!
+})`, + ].join('\n\n') + + if (replaced !== routeCode) { + console.log(`[emoticon] Updating ${node.fullPath}`) + await fsp.writeFile(node.fullPath, replaced) + } + } + + if (node.parent) { + node.parent.children = node.parent.children ?? [] + node.parent.children.push(node) + } + routeNodes.push(node) + } + + for (const node of preRouteNodes) { + await handleNode(node) + } + + function buildRouteConfig(nodes: RouteNode[], depth = 1): string { + const children = nodes.map((node) => { + if (node.isLayout) { + return + } + + if (node.isLayout && !node.children?.length) { + return + } + + const route = `${node.variableName}Route` + + if (node.children?.length) { + const childConfigs = buildRouteConfig(node.children, depth + 1) + return `${route}.addChildren([${spaces(depth * 4)}${childConfigs}])` + } + + return route + }) + + return children.filter(Boolean).join(`,`) + } + + const routeConfigChildrenText = buildRouteConfig(routeTree) + + const sortedRouteNodes = multiSortBy(routeNodes, [ + (d): number => (d.routePath.includes(`/${ROOT_PATH_ID}`) ? -1 : 1), + (d): number => d.routePath.split('/').length, + (d): number => (d.routePath.endsWith("index'") ? -1 : 1), + (d): any => d, + ]) + + const imports = Object.entries({ + createFileRoute: false, + lazyFn: sortedRouteNodes.some( + (node) => routePiecesByPath[node.routePath]?.loader, + ), + lazyRouteComponent: sortedRouteNodes.some( + (node) => + routePiecesByPath[node.routePath]?.component || + routePiecesByPath[node.routePath]?.errorComponent || + routePiecesByPath[node.routePath]?.pendingComponent, + ), + }) + .filter((d) => d[1]) + .map((d) => d[0]) + + const routeImports = [ + '// This file is auto-generated by Tuono', + imports.length + ? `import { ${imports.join(', ')} } from '@tanstack/react-router'\n` + : '', + '// Import Routes', + [ + `import { Route as rootRoute } from './${replaceBackslash( + path.relative( + path.dirname(config.generatedRouteTree), + path.resolve(config.folderName, ROOT_PATH_ID), + ), + )}'`, + ...sortedRouteNodes.map((node) => { + return `import { Route as ${ + node.variableName + }Import } from './${replaceBackslash( + removeExt( + path.relative( + path.dirname(config.generatedRouteTree), + path.resolve(config.folderName, node.filePath), + ), + false, + ), + )}'` + }), + ].join('\n'), + '// Create/Update Routes', + sortedRouteNodes + .map((node) => { + const loaderNode = routePiecesByPath[node.routePath]?.loader + const lazyComponentNode = routePiecesByPath[node.routePath]?.lazy + + return [ + `const ${node.variableName}Route = ${node.variableName}Import.update({ + ${[ + `path: '${node.cleanedPath}'`, + `getParentRoute: () => ${node.parent?.variableName ?? 'root'}Route`, + ] + .filter(Boolean) + .join(',')} + } as any)`, + loaderNode + ? `.updateLoader({ loader: lazyFn(() => import('./${replaceBackslash( + removeExt( + path.relative( + path.dirname(config.generatedRouteTree), + path.resolve(config.folderName, loaderNode.filePath), + ), + false, + ), + )}'), 'loader') })` + : '', + lazyComponentNode + ? `.lazy(() => import('./${replaceBackslash( + removeExt( + path.relative( + path.dirname(config.generatedRouteTree), + path.resolve(config.folderName, lazyComponentNode.filePath), + ), + false, + ), + )}').then((d) => d.Route))` + : '', + ].join('') + }) + .join('\n\n'), + ...[ + '// Populate the FileRoutesByPath interface', + `declare module '@tanstack/react-router' { + interface FileRoutesByPath { + ${routeNodes + .map((routeNode) => { + return `'${removeTrailingUnderscores(routeNode.routePath)}': { + preLoaderRoute: typeof ${routeNode.variableName}Import + parentRoute: typeof ${ + routeNode.parent?.variableName + ? `${routeNode.parent.variableName}Import` + : 'rootRoute' + } + }` + }) + .join('\n')} + } +}`, + ], + '// Create and export the route tree', + `export const routeTree = rootRoute.addChildren([${routeConfigChildrenText}])`, + ] + .filter(Boolean) + .join('\n\n') + + console.log(routeImports) + + const routeConfigFileContent = await format(routeImports, { + semi: false, + singleQuote: true, + parser: 'typescript', + }) + + const routeTreeContent = await fsp + .readFile(path.resolve(config.generatedRouteTree), 'utf-8') + .catch((err: any) => { + if (err.code === 'ENOENT') { + return undefined + } + throw err + }) + + if (!checkLatest()) return + + if (routeTreeContent !== routeConfigFileContent) { + await fsp.mkdir(path.dirname(path.resolve(config.generatedRouteTree)), { + recursive: true, + }) + if (!checkLatest()) return + await fsp.writeFile( + path.resolve(config.generatedRouteTree), + routeConfigFileContent, + ) + } + + console.log( + `[emoticon] Processed ${routeNodes.length === 1 ? 'route' : 'routes'} in ${ + Date.now() - start + }ms`, + ) +} + +function spaces(d: number): string { + return Array.from({ length: d }) + .map(() => ' ') + .join('') +} + +function removeTrailingUnderscores(s?: string): string | undefined { + return s?.replaceAll(/(_$)/gi, '').replaceAll(/(_\/)/gi, '/') +} +/** + * 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' + */ +function removeLayoutSegments(routePath: string = '/'): string { + const segments = routePath.split('/') + const newSegments = segments.filter((segment) => !segment.startsWith('_')) + return newSegments.join('/') +} + +function removeGroups(s: string): string { + return s.replaceAll(routeGroupPatternRegex, '').replaceAll('//', '/') +} +export function trimPathLeft(pathToTrim: string): string { + return pathToTrim === '/' ? pathToTrim : pathToTrim.replace(/^\/{1,}/, '') +} + +/** + * 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. + */ +function determineNodePath(node: RouteNode): string { + return (node.path = node.parent + ? node.routePath.replace(node.parent.routePath, '') || '/' + : node.routePath) +} diff --git a/packages/router-generator/src/generator.ts b/packages/router-generator/src/generator.ts new file mode 100644 index 00000000..3d2b2c7f --- /dev/null +++ b/packages/router-generator/src/generator.ts @@ -0,0 +1,379 @@ +import * as fs from 'fs' +import * as fsp from 'fs/promises' +import path from 'path' + +import { + cleanPath, + determineNodePath, + multiSortBy, + spaces, + replaceBackslash, + removeExt, + routePathToVariable, + removeGroups, + removeUnderscores, + removeLayoutSegments, + removeTrailingUnderscores, +} from './utils' + +import type { Config, RouteNode, RouteSubNode } from './types' + +import { format } from 'prettier' + +const ROUTES_FOLDER = './src/routes/' +const ROOT_PATH_ID = '__root' +const GENERATED_ROUTE_TREE = './src/routeTree.gen.ts' + +let latestTask = 0 + +const defaultConfig: Config = { + folderName: ROUTES_FOLDER, + generatedRouteTree: GENERATED_ROUTE_TREE, +} + +let isFirst = false +let skipMessage = false + +async function getRouteNodes(config = defaultConfig): Promise { + const routeNodes: RouteNode[] = [] + + async function recurse(dir: string): Promise { + const fullDir = path.resolve(config.folderName, dir) + const dirList = await fsp.readdir(fullDir, { withFileTypes: true }) + + await Promise.all( + dirList.map(async (dirent) => { + const fullPath = path.join(fullDir, dirent.name) + const relativePath = path.join(dir, dirent.name) + + if (dirent.isDirectory()) { + await recurse(relativePath) + } else if (fullPath.match(/\.(tsx|ts|jsx|js)$/)) { + const filePath = replaceBackslash(path.join(dir, dirent.name)) + const filePathNoExt = removeExt(filePath) + let routePath = + cleanPath(`/${filePathNoExt.split('.').join('/')}`) || '' + + const variableName = routePathToVariable(routePath) + + // Remove the index from the route path and + // if the route path is empty, use `/' + + const isLoader = routePath.includes('-loading') + + if (routePath === 'index') { + routePath = '/' + } + + routePath = routePath.replace(/\/index$/, '/') || '/' + + routeNodes.push({ + filePath, + fullPath, + routePath, + isLoader, + variableName, + }) + } + }), + ) + } + + await recurse('./') + + return routeNodes +} + +export function hasParentRoute( + routes: RouteNode[], + node: RouteNode, + routePathToCheck: string | undefined, +): RouteNode | null { + if (!routePathToCheck || routePathToCheck === '/') { + return null + } + + const sortedNodes = multiSortBy(routes, [ + (d): number => d.routePath.length * -1, + (d): string | undefined => d.variableName, + ]).filter((d) => d.routePath !== `/${ROOT_PATH_ID}`) + + 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) +} + +export async function routeGenerator(config = defaultConfig): Promise { + if (!isFirst) { + console.log('Generating routes...') + isFirst = true + } else if (skipMessage) { + skipMessage = false + } else { + console.log('Regenerating routes') + } + + const checkLatest = (): boolean => { + if (latestTask !== taskId) { + skipMessage = true + return false + } + + return true + } + + const taskId = latestTask + 1 + latestTask = taskId + + const start = Date.now() + const beforeRouteNodes = await getRouteNodes(config) + + const preRouteNodes = multiSortBy(beforeRouteNodes, [ + (d): number => (d.routePath === '/' ? -1 : 1), + (d): number => d.routePath.split('/').length, + (d): number => (d.filePath.match(/[./]index[.]/) ? 1 : -1), + (d): number => + d.filePath.match( + /[./](component|errorComponent|pendingComponent|loader|lazy)[.]/, + ) + ? 1 + : -1, + (d): number => (d.filePath.match(/[./]route[.]/) ? -1 : 1), + (d): number => (d.routePath.endsWith('/') ? -1 : 1), + (d): string => d.routePath, + ]).filter((d) => ![`/${ROOT_PATH_ID}`].includes(d.routePath || '')) + + const routeTree: RouteNode[] = [] + const routePiecesByPath: Record = {} + + // Loop over the flat list of routeNodes and + // build up a tree based on the routeNodes' routePath + const routeNodes: RouteNode[] = [] + + const handleNode = async (node: RouteNode): Promise => { + const parentRoute = hasParentRoute(routeNodes, node, node.routePath) + + if (parentRoute) node.parent = parentRoute + + node.path = determineNodePath(node) + + node.cleanedPath = removeGroups( + removeUnderscores(removeLayoutSegments(node.path)) ?? '', + ) + + const routeCode = fs.readFileSync(node.fullPath, 'utf-8') + + const escapedRoutePath = removeTrailingUnderscores( + node.routePath.replaceAll('$', '$$'), + ) + + let replaced = routeCode + + if (!routeCode) { + replaced = [ + `import { createFileRoute } from '@tanstack/react-router'`, + `export const Route = createFileRoute('${escapedRoutePath}')({ + component: () =>
Hello ${escapedRoutePath}!
+})`, + ].join('\n\n') + + if (replaced !== routeCode) { + console.log(`[emoticon] Updating ${node.fullPath}`) + await fsp.writeFile(node.fullPath, replaced) + } + } + + if (node.parent) { + node.parent.children = node.parent.children ?? [] + node.parent.children.push(node) + } + console.log('pushed') + routeNodes.push(node) + } + + for (const node of preRouteNodes) { + await handleNode(node) + } + + function buildRouteConfig(nodes: RouteNode[], depth = 1): string { + const children = nodes.map((node) => { + if (node.isLayout) { + return + } + + if (node.isLayout && !node.children?.length) { + return + } + + const route = `${node.variableName}Route` + + if (node.children?.length) { + const childConfigs = buildRouteConfig(node.children, depth + 1) + return `${route}.addChildren([${spaces(depth * 4)}${childConfigs}])` + } + + return route + }) + + return children.filter(Boolean).join(`,`) + } + + const routeConfigChildrenText = buildRouteConfig(routeNodes) + + const sortedRouteNodes = multiSortBy(routeNodes, [ + (d): number => (d.routePath.includes(`/${ROOT_PATH_ID}`) ? -1 : 1), + (d): number => d.routePath.split('/').length, + (d): number => (d.routePath.endsWith("index'") ? -1 : 1), + (d): any => d, + ]) + + const imports = Object.entries({ + createFileRoute: false, + lazyFn: sortedRouteNodes.some( + (node) => routePiecesByPath[node.routePath]?.loader, + ), + lazyRouteComponent: sortedRouteNodes.some( + (node) => + routePiecesByPath[node.routePath]?.component || + routePiecesByPath[node.routePath]?.errorComponent || + routePiecesByPath[node.routePath]?.pendingComponent, + ), + }) + .filter((d) => d[1]) + .map((d) => d[0]) + + const routeImports = [ + '// This file is auto-generated by Tuono', + [ + `import { Route as rootRoute } from './${replaceBackslash( + path.relative( + path.dirname(config.generatedRouteTree), + path.resolve(config.folderName, ROOT_PATH_ID), + ), + )}'`, + ...sortedRouteNodes.map((node) => { + return `import { Route as ${ + node.variableName + }Import } from './${replaceBackslash( + removeExt( + path.relative( + path.dirname(config.generatedRouteTree), + path.resolve(config.folderName, node.filePath), + ), + false, + ), + )}'` + }), + ].join('\n'), + '// Create/Update Routes', + sortedRouteNodes + .map((node) => { + const loaderNode = routePiecesByPath[node.routePath]?.loader + const lazyComponentNode = routePiecesByPath[node.routePath]?.lazy + + return [ + `const ${node.variableName}Route = ${node.variableName}Import.update({ + ${[ + `path: '${node.cleanedPath}'`, + `getParentRoute: () => ${node.parent?.variableName ?? 'root'}Route`, + ] + .filter(Boolean) + .join(',')} + } as any)`, + loaderNode + ? `.updateLoader({ loader: lazyFn(() => import('./${replaceBackslash( + removeExt( + path.relative( + path.dirname(config.generatedRouteTree), + path.resolve(config.folderName, loaderNode.filePath), + ), + false, + ), + )}'), 'loader') })` + : '', + lazyComponentNode + ? `.lazy(() => import('./${replaceBackslash( + removeExt( + path.relative( + path.dirname(config.generatedRouteTree), + path.resolve(config.folderName, lazyComponentNode.filePath), + ), + false, + ), + )}').then((d) => d.Route))` + : '', + ].join('') + }) + .join('\n\n'), + ...[ + '// Populate the FileRoutesByPath interface', + `declare module '@tanstack/react-router' { + interface FileRoutesByPath { + ${routeNodes + .map((routeNode) => { + return `'${removeTrailingUnderscores(routeNode.routePath)}': { + preLoaderRoute: typeof ${routeNode.variableName}Import + parentRoute: typeof ${ + routeNode.parent?.variableName + ? `${routeNode.parent.variableName}Import` + : 'rootRoute' + } + }` + }) + .join('\n')} + } +}`, + ], + '// Create and export the route tree', + `export const routeTree = rootRoute.addChildren([${routeConfigChildrenText}])`, + ] + .filter(Boolean) + .join('\n\n') + + const routeConfigFileContent = await format(routeImports, { + semi: false, + singleQuote: true, + parser: 'typescript', + }) + + const routeTreeContent = await fsp + .readFile(path.resolve(config.generatedRouteTree), 'utf-8') + .catch((err: any) => { + if (err.code === 'ENOENT') { + return undefined + } + throw err + }) + + if (!checkLatest()) return + + if (routeTreeContent !== routeConfigFileContent) { + await fsp.mkdir(path.dirname(path.resolve(config.generatedRouteTree)), { + recursive: true, + }) + if (!checkLatest()) return + await fsp.writeFile( + path.resolve(config.generatedRouteTree), + routeConfigFileContent, + ) + } + + console.log( + `[emoticon] Processed ${routeNodes.length === 1 ? 'route' : 'routes'} in ${ + Date.now() - start + }ms`, + ) +} diff --git a/packages/router-generator/src/index.ts b/packages/router-generator/src/index.ts index 0f5e8847..75e568f3 100644 --- a/packages/router-generator/src/index.ts +++ b/packages/router-generator/src/index.ts @@ -1,3 +1,21 @@ -export function routeGenerator(): void { - console.log('route generator') -} +/** + * Overview: + * + * src/routes = Routes entry point + * src/routes/layout.tsx = Shared layout + * src/routes/index.tsx = xyz.com + * src/routes/about.tsx = xyz.com/about + * src/routes/about-loading.tsx = xyz.com/about - while loading data + * src/routes/about/index.tsx = xyz.com/about + * src/routes/posts/[slug].tsx = xyz.com/posts/my-lovely-post + * src/routes/posts/[...params].tsx = xyz.com/posts/my-lovely-post/commend-id-304 + * src/routes/404.tsx = Not found + * + * public/ = Public files + * + * All the routes are lazy loaded! + */ + +import { routeGenerator } from './generator' + +export { routeGenerator } diff --git a/packages/router-generator/src/types.ts b/packages/router-generator/src/types.ts new file mode 100644 index 00000000..77d18743 --- /dev/null +++ b/packages/router-generator/src/types.ts @@ -0,0 +1,28 @@ +export interface RouteNode { + filePath: string + fullPath: string + routePath: string + path?: string + cleanedPath?: string + isLayout?: boolean + isLoader: boolean + children?: RouteNode[] + parent?: RouteNode + variableName?: string +} + +/** + * @deprecated + */ +export interface RouteSubNode { + component?: RouteNode + errorComponent?: RouteNode + pendingComponent?: RouteNode + loader?: RouteNode + lazy?: RouteNode +} + +export interface Config { + folderName: string + generatedRouteTree: string +} diff --git a/packages/router-generator/src/utils.ts b/packages/router-generator/src/utils.ts new file mode 100644 index 00000000..35b35ee5 --- /dev/null +++ b/packages/router-generator/src/utils.ts @@ -0,0 +1,113 @@ +import type { RouteNode } from './types' + +const ROUTE_GROUP_PATTERN_REGEX = /\(.+\)/g + +export function removeExt(d: string, keepExtension: boolean = false): string { + return keepExtension ? d : d.substring(0, d.lastIndexOf('.')) || d +} + +export function replaceBackslash(s: string): string { + return s.replaceAll(/\\/gi, '/') +} + +export function cleanPath(pathToClean: string): string { + // remove double slashes + return pathToClean.replace(/\/{2,}/g, '/') +} + +export function removeUnderscores(s?: string): string | undefined { + return s?.replaceAll(/(^_|_$)/gi, '').replaceAll(/(\/_|_\/)/gi, '/') +} + +export function routePathToVariable(routePath: string): string { + return ( + removeUnderscores(routePath) + ?.replace(/\/\$\//g, '/splat/') + .replace(/\$$/g, 'splat') + .replace(/\$/g, '') + .split(/[/-]/g) + .map((d, i) => (i > 0 ? capitalize(d) : d)) + .join('') + .replace(/([^a-zA-Z0-9]|[.])/gm, '') + .replace(/^(\d)/g, 'R$1') ?? '' + ) +} + +export function multiSortBy( + arr: T[], + accessors: ((item: T) => any)[] = [(d): any => d], +): 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 capitalize(s: string): string { + if (typeof s !== 'string') return '' + return s.charAt(0).toUpperCase() + s.slice(1) +} + +export function spaces(d: number): string { + return Array.from({ length: d }) + .map(() => ' ') + .join('') +} + +export function removeTrailingUnderscores(s?: string): string | undefined { + return s?.replaceAll(/(_$)/gi, '').replaceAll(/(_\/)/gi, '/') +} + +/** + * 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('/') +} + +export function removeGroups(s: string): string { + return s.replaceAll(ROUTE_GROUP_PATTERN_REGEX, '').replaceAll('//', '/') +} + +export function trimPathLeft(pathToTrim: string): string { + return pathToTrim === '/' ? pathToTrim : pathToTrim.replace(/^\/{1,}/, '') +} + +/** + * 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): string { + return (node.path = node.parent + ? node.routePath.replace(node.parent.routePath, '') || '/' + : node.routePath) +} diff --git a/packages/router-generator/tests/generator.test.ts b/packages/router-generator/tests/generator.test.ts new file mode 100644 index 00000000..914956d1 --- /dev/null +++ b/packages/router-generator/tests/generator.test.ts @@ -0,0 +1,41 @@ +import fs from 'fs/promises' +import { describe, it, expect } from 'vitest' +import { routeGenerator } from '../src' + +function makeFolderDir(folder: string) { + return process.cwd() + `/tests/generator/${folder}` +} + +async function getRouteTreeFileText(folder: string) { + const dir = makeFolderDir(folder) + return await fs.readFile(dir + '/routeTree.gen.ts', 'utf-8') +} + +async function getExpectedRouteTreeFileText(folder: string) { + const dir = makeFolderDir(folder) + const location = dir + '/routeTree.expected.ts' + return await fs.readFile(location, 'utf-8') +} + +describe('generator works', async () => { + const folderNames = await fs.readdir(process.cwd() + '/tests/generator') + + it.each(folderNames.map((folder) => [folder]))( + 'should wire-up the routes for a "%s" tree', + async (folderName) => { + const currentFolder = `${process.cwd()}/tests/generator/${folderName}` + + await routeGenerator({ + folderName: `${currentFolder}/routes`, + generatedRouteTree: `${currentFolder}/routeTree.gen.ts`, + }) + + const [expectedRouteTree, generatedRouteTree] = await Promise.all([ + getExpectedRouteTreeFileText(folderName), + getRouteTreeFileText(folderName), + ]) + + expect(generatedRouteTree).equal(expectedRouteTree) + }, + ) +}) diff --git a/packages/router-generator/tests/generator/single-level/routeTree.expected.ts b/packages/router-generator/tests/generator/single-level/routeTree.expected.ts new file mode 100644 index 00000000..ac2d0eb0 --- /dev/null +++ b/packages/router-generator/tests/generator/single-level/routeTree.expected.ts @@ -0,0 +1,36 @@ +// This file is auto-generated by Tuono + +import { Route as rootRoute } from './routes/__root' +import { Route as AboutImport } from './routes/about' +import { Route as IndexImport } from './routes/index' + +// Create/Update Routes + +const AboutRoute = AboutImport.update({ + path: '/about', + getParentRoute: () => rootRoute, +} as any) + +const IndexRoute = IndexImport.update({ + path: '/', + getParentRoute: () => rootRoute, +} as any) + +// Populate the FileRoutesByPath interface + +declare module '@tanstack/react-router' { + interface FileRoutesByPath { + '/': { + preLoaderRoute: typeof IndexImport + parentRoute: typeof rootRoute + } + '/about': { + preLoaderRoute: typeof AboutImport + parentRoute: typeof rootRoute + } + } +} + +// Create and export the route tree + +export const routeTree = rootRoute.addChildren([IndexRoute, AboutRoute]) diff --git a/packages/router-generator/tests/generator/single-level/routes/__root.tsx b/packages/router-generator/tests/generator/single-level/routes/__root.tsx new file mode 100644 index 00000000..ab504e42 --- /dev/null +++ b/packages/router-generator/tests/generator/single-level/routes/__root.tsx @@ -0,0 +1 @@ +/** */ diff --git a/packages/router-generator/tests/generator/single-level/routes/about.tsx b/packages/router-generator/tests/generator/single-level/routes/about.tsx new file mode 100644 index 00000000..ab504e42 --- /dev/null +++ b/packages/router-generator/tests/generator/single-level/routes/about.tsx @@ -0,0 +1 @@ +/** */ diff --git a/packages/router-generator/tests/generator/single-level/routes/index.tsx b/packages/router-generator/tests/generator/single-level/routes/index.tsx new file mode 100644 index 00000000..ab504e42 --- /dev/null +++ b/packages/router-generator/tests/generator/single-level/routes/index.tsx @@ -0,0 +1 @@ +/** */ diff --git a/turbo.json b/turbo.json index b2f1fbf4..f4c609e1 100644 --- a/turbo.json +++ b/turbo.json @@ -2,6 +2,7 @@ "$schema": "https://turbo.build/schema.json", "pipeline": { "lint": {}, - "format": {} + "format": {}, + "test": {} } }