Crate standalone fs router package (#11)

* refactor: moved fs-router to standalone package

* chore: remove useless split source function

* feat: add simple test cases

* fix: README formatting

* fix: remove useless RouteSubNode

* feat: update version to v0.4.4
This commit is contained in:
Valerio Ageno
2024-07-08 21:39:00 +02:00
committed by GitHub
parent bc7ffc2246
commit e4d4a75b88
31 changed files with 261 additions and 724 deletions
@@ -0,0 +1,3 @@
export const ROUTES_FOLDER = './src/routes/'
export const ROOT_PATH_ID = '__root'
export const GENERATED_ROUTE_TREE = './.tuono/routeTree.gen.ts'
@@ -0,0 +1,312 @@
import * as fsp from 'fs/promises'
import path from 'path'
import {
cleanPath,
determineNodePath,
multiSortBy,
spaces,
replaceBackslash,
removeExt,
routePathToVariable,
removeGroups,
removeUnderscores,
removeLayoutSegments,
} from './utils'
import type { Config, RouteNode } from './types'
import { ROUTES_FOLDER, ROOT_PATH_ID, GENERATED_ROUTE_TREE } from './constants'
import { format } from 'prettier'
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<{ routeNodes: RouteNode[]; rustHandlersNodes: string[] }> {
const routeNodes: RouteNode[] = []
const rustHandlersNodes: string[] = []
async function recurse(dir: string): Promise<void> {
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,
})
} else if (fullPath.match(/\.(rs)$/)) {
const filePath = replaceBackslash(path.join(dir, dirent.name))
const filePathNoExt = removeExt(filePath)
let routePath =
cleanPath(`/${filePathNoExt.split('.').join('/')}`) || ''
if (routePath === 'index') {
routePath = '/'
}
routePath = routePath.replace(/\/index$/, '/') || '/'
rustHandlersNodes.push(routePath)
}
}),
)
}
await recurse('./')
return { routeNodes, rustHandlersNodes }
}
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<void> {
if (!isFirst) {
isFirst = true
} else if (skipMessage) {
skipMessage = false
}
const checkLatest = (): boolean => {
if (latestTask !== taskId) {
skipMessage = true
return false
}
return true
}
const taskId = latestTask + 1
latestTask = taskId
const { routeNodes: beforeRouteNodes, rustHandlersNodes } =
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 || ''))
// 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<void> => {
const parentRoute = hasParentRoute(routeNodes, node, node.routePath)
if (parentRoute) node.parent = parentRoute
node.path = determineNodePath(node)
node.cleanedPath = removeGroups(
removeUnderscores(removeLayoutSegments(node.path)) ?? '',
)
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
}
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 = [
...sortedRouteNodes.map((node) => {
return `const ${
node.variableName
}Import = dynamic(() => import('./${replaceBackslash(
removeExt(
path.relative(
path.dirname(config.generatedRouteTree),
path.resolve(config.folderName, node.filePath),
),
false,
),
)}'))`
}),
].join('\n')
const createRoutes = [
...sortedRouteNodes.map((node) => {
return `const ${node.variableName} = createRoute({ component: ${node.variableName}Import })`
}),
].join('\n')
const createRouteUpdates = [
sortedRouteNodes
.map((node) => {
return [
`const ${node.variableName}Route = ${node.variableName}.update({
${[
`path: '${node.cleanedPath}'`,
`getParentRoute: () => ${node.parent?.variableName ?? 'root'}Route`,
rustHandlersNodes.includes(node.path || '')
? 'hasHandler: true'
: '',
]
.filter(Boolean)
.join(',')}
})`,
].join('')
})
.join('\n\n'),
]
const routeImports = [
'// This file is auto-generated by Tuono',
"import { createRoute, dynamic } from 'tuono'",
[
`import RootImport from './${replaceBackslash(
path.relative(
path.dirname(config.generatedRouteTree),
path.resolve(config.folderName, ROOT_PATH_ID),
),
)}'`,
].join('\n'),
imports,
'const rootRoute = createRoute({ isRoot: true, component: RootImport });',
createRoutes,
'// Create/Update Routes',
createRouteUpdates,
'// 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,
)
}
}
@@ -0,0 +1,47 @@
import { routeGenerator } from './generator'
import { normalize } from 'path'
import type { Plugin } from 'vite'
const ROUTES_DIRECTORY_PATH = './src/routes'
let lock = false
export default function RouterGenerator(): Plugin {
const generate = async (): Promise<void> => {
if (lock) return
lock = true
try {
await routeGenerator()
} catch (err) {
console.error(err)
} finally {
lock = false
}
}
const handleFile = async (file: string): Promise<void> => {
const filePath = normalize(file)
if (filePath.startsWith(ROUTES_DIRECTORY_PATH)) {
await generate()
}
}
return {
name: 'vite-plugin-tuono-fs-router',
configResolved: async (): Promise<void> => {
await generate()
},
watchChange: async (
file: string,
context: { event: string },
): Promise<void> => {
if (['create', 'update', 'delete'].includes(context.event)) {
await handleFile(file)
}
},
}
}
@@ -0,0 +1,17 @@
export interface RouteNode {
filePath: string
fullPath: string
routePath: string
path?: string
cleanedPath?: string
isLayout?: boolean
isLoader: boolean
children?: RouteNode[]
parent?: RouteNode
variableName?: string
}
export interface Config {
folderName: string
generatedRouteTree: string
}
+113
View File
@@ -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<T>(
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)
}