|
import type { Plugin } from 'vite'; |
|
import * as path from 'path'; |
|
import { glob } from 'glob'; |
|
|
|
export interface ScanOptions { |
|
page?: boolean; |
|
route?: boolean; |
|
layout?: boolean; |
|
error?: boolean; |
|
fallback?: boolean; |
|
} |
|
|
|
export interface DataRoutePluginOptions { |
|
root?: string; |
|
clientScan?: ScanOptions | false; |
|
serverScan?: ScanOptions | false; |
|
lazy?: boolean; |
|
} |
|
|
|
interface RouteObjectTemplate { |
|
path?: string; |
|
index?: boolean; |
|
children?: RouteObjectTemplate[]; |
|
componentImport?: string; |
|
loaderImport?: string; |
|
actionImport?: string; |
|
layoutImport?: string; |
|
errorBoundaryImport?: string; |
|
hydrateFallbackImport?: string; |
|
isLazy?: boolean; |
|
filePath?: string; |
|
dir?: string; |
|
} |
|
|
|
interface RouteFile { |
|
type: 'page' | 'route' | 'layout' | 'error' | 'fallback'; |
|
filePath: string; |
|
routePath: string; |
|
isFlat: boolean; |
|
dir: string; |
|
routeType?: 'static' | 'dynamic' | 'flat' | 'group' | 'catch-all'; |
|
priority?: number; |
|
} |
|
|
|
interface ImportInfo { |
|
importName: string; |
|
filePath: string; |
|
} |
|
|
|
class InvalidRouteError extends Error { |
|
constructor(message: string, filePath: string) { |
|
super(`${message} at ${filePath}`); |
|
this.name = 'InvalidRouteError'; |
|
} |
|
} |
|
|
|
class DuplicateRouteError extends Error { |
|
constructor(message: string, paths: string[]) { |
|
super(`${message}: ${paths.join(', ')}`); |
|
this.name = 'DuplicateRouteError'; |
|
} |
|
} |
|
|
|
const dirSplitRx = /[/\\]/; |
|
|
|
function getRouteType( |
|
file: RouteFile |
|
): 'static' | 'dynamic' | 'flat' | 'group' | 'catch-all' { |
|
const { routePath, isFlat, filePath } = file; |
|
// 윈도우 경로를 POSIX 스타일로 정규화 |
|
const normalizedFilePath = filePath.split(path.sep).join('/'); |
|
|
|
// 캐치올 라우트 (최우선 체크) |
|
if (routePath.includes('*') || normalizedFilePath.includes('$/')) { |
|
return 'catch-all'; |
|
} |
|
|
|
// 그룹 라우트 |
|
if (normalizedFilePath.includes('(') && normalizedFilePath.includes(')')) { |
|
return 'group'; |
|
} |
|
|
|
// 플랫 라우트 |
|
if (isFlat) { |
|
return 'flat'; |
|
} |
|
|
|
// 동적 라우트 |
|
if (routePath.includes(':')) { |
|
return 'dynamic'; |
|
} |
|
|
|
// 정적 라우트 |
|
return 'static'; |
|
} |
|
|
|
function calculateRoutePriority(file: RouteFile): number { |
|
const routeType = getRouteType(file); |
|
const { routePath } = file; |
|
|
|
// 기본 우선순위 (낮을수록 먼저 매칭) |
|
const basePriority = { |
|
static: 1000, // 정적 라우트 최우선 |
|
dynamic: 2000, // 동적 라우트 다음 |
|
flat: 3000, // 플랫 라우트 그 다음 |
|
group: 4000, // 그룹 라우트 그 다음 |
|
'catch-all': 9000, // 캐치올 라우트 항상 맨 뒤 |
|
}[routeType]; |
|
|
|
// 경로 깊이에 따른 조정 (깊을수록 우선순위 높음) |
|
const pathDepth = routePath.split('/').filter(Boolean).length; |
|
const depthBonus = pathDepth * 10; |
|
|
|
// 동적 세그먼트 개수에 따른 조정 (적을수록 우선순위 높음) |
|
const dynamicSegments = (routePath.match(/:/g) || []).length; |
|
const dynamicPenalty = dynamicSegments * 100; |
|
|
|
return basePriority - depthBonus + dynamicPenalty; |
|
} |
|
|
|
export default function reactRoutePlugin( |
|
opts: DataRoutePluginOptions = {} |
|
): Plugin[] { |
|
const options = { |
|
root: opts.root || './src/app/pages', |
|
clientScan: |
|
opts.clientScan !== false |
|
? { |
|
page: true, |
|
route: true, |
|
layout: true, |
|
error: true, |
|
fallback: true, |
|
...opts.clientScan, |
|
} |
|
: (false as const), |
|
serverScan: |
|
opts.serverScan !== false |
|
? { |
|
page: true, |
|
route: true, |
|
layout: true, |
|
error: true, |
|
fallback: true, |
|
...opts.serverScan, |
|
} |
|
: (false as const), |
|
lazy: opts.lazy !== false, |
|
}; |
|
|
|
let routeFiles: RouteFile[] = []; |
|
let clientCode: string = ''; |
|
let serverCode: string = ''; |
|
|
|
function isFlatRouting(filePath: string): boolean { |
|
// 윈도우 경로를 POSIX 스타일로 정규화 |
|
const normalizedPath = filePath.split(path.sep).join('/'); |
|
const segments = path.dirname(normalizedPath).split('/').filter(Boolean); |
|
return segments.some((segment) => { |
|
const trimmed = segment.replace(/^\.+|\.+$/g, ''); |
|
return trimmed.includes('.'); |
|
}); |
|
} |
|
|
|
function scanFiles(): RouteFile[] { |
|
const rootPath = path.resolve(options.root); |
|
const files: RouteFile[] = []; |
|
|
|
const patterns = [ |
|
'**/route.{js,ts}', |
|
'**/{page,layout,error,fallback}.{js,jsx,ts,tsx}', |
|
]; |
|
|
|
patterns.forEach((pattern) => { |
|
const foundFiles = glob.sync(pattern, { cwd: rootPath }); |
|
foundFiles.forEach((file) => { |
|
const fullPath = path.join(rootPath, file); |
|
const parsed = path.parse(file); |
|
const type = parsed.name as RouteFile['type']; |
|
const dir = path.dirname(fullPath); |
|
|
|
const isFlat = isFlatRouting(file); |
|
|
|
if (isFlat && file.includes('(')) { |
|
throw new InvalidRouteError( |
|
'Group routing not allowed in flat routing', |
|
fullPath |
|
); |
|
} |
|
|
|
const routePath = generateRoutePath(file, isFlat); |
|
|
|
files.push({ |
|
type, |
|
filePath: fullPath, |
|
routePath, |
|
isFlat, |
|
dir, |
|
}); |
|
}); |
|
}); |
|
|
|
return files; |
|
} |
|
|
|
function generateRoutePath(filePath: string, isFlat: boolean): string { |
|
// 윈도우 경로를 POSIX 스타일로 정규화 |
|
const normalizedPath = filePath.split(path.sep).join('/'); |
|
const parsed = path.parse(normalizedPath); |
|
|
|
if (isFlat) { |
|
const segments = parsed.name.split('.'); |
|
const pathSegments = segments.map(convertSegment).filter(Boolean); |
|
const routePath = '/' + pathSegments.join('/'); |
|
|
|
// 캐치올 라우트가 루트 경로가 되는 것을 방지 |
|
if (routePath === '/' && segments.includes('$')) { |
|
return '/*'; // 명시적으로 캐치올 라우트로 설정 |
|
} |
|
|
|
return routePath; |
|
} else { |
|
const segments = path.dirname(normalizedPath).split('/').filter(Boolean); |
|
const pathSegments = segments.map(convertSegment).filter(Boolean); |
|
const routePath = '/' + pathSegments.join('/'); |
|
|
|
// 캐치올 라우트가 루트 경로가 되는 것을 방지 |
|
if (routePath === '/' && segments.includes('$')) { |
|
return '/*'; // 명시적으로 캐치올 라우트로 설정 |
|
} |
|
|
|
return routePath; |
|
} |
|
} |
|
|
|
function convertSegment(segment: string): string { |
|
if (segment.startsWith('(') && segment.endsWith(')')) { |
|
return ''; |
|
} |
|
if (segment.includes('$$')) { |
|
return segment.replace(/\$\$/g, '$'); |
|
} |
|
if (segment.startsWith('$') && segment.endsWith('$')) { |
|
const param = segment.slice(1, -1); |
|
if (!param) return ''; |
|
return `:${param}?`; |
|
} |
|
if (segment.startsWith('$')) { |
|
return `:${segment.slice(1)}`; |
|
} |
|
if (segment === '$') { |
|
return '*'; |
|
} |
|
return segment; |
|
} |
|
|
|
function validateRoutes(files: RouteFile[]): void { |
|
// 모든 파일에 라우트 타입과 우선순위 설정 |
|
files.forEach((file) => { |
|
file.routeType = getRouteType(file); |
|
file.priority = calculateRoutePriority(file); |
|
}); |
|
|
|
const routePaths = new Map<string, RouteFile[]>(); |
|
|
|
files.forEach((file) => { |
|
if (file.type === 'page' || file.type === 'route') { |
|
const existing = routePaths.get(file.routePath) || []; |
|
existing.push(file); |
|
routePaths.set(file.routePath, existing); |
|
} |
|
}); |
|
|
|
routePaths.forEach((routeFiles, routePath) => { |
|
if (routeFiles.length > 1) { |
|
// 캐치올 라우트와 일반 라우트가 동일한 경로에 있는 경우 |
|
const hasCatchAll = routeFiles.some((f) => f.routeType === 'catch-all'); |
|
const hasNonCatchAll = routeFiles.some( |
|
(f) => f.routeType !== 'catch-all' |
|
); |
|
|
|
if (hasCatchAll && hasNonCatchAll) { |
|
console.warn( |
|
`주의: 캐치올 라우트와 일반 라우트 충돌 감지됨: ${routePath}` |
|
); |
|
console.warn(`파일: ${routeFiles.map((f) => f.filePath).join(', ')}`); |
|
// 캐치올과 일반 경로가 충돌하는 경우는 별도 처리하기 위해 에러를 던지지 않음 |
|
return; |
|
} |
|
|
|
// page와 route 조합은 허용 |
|
const types = routeFiles.map((f) => f.type); |
|
const hasPage = types.includes('page'); |
|
const hasRoute = types.includes('route'); |
|
|
|
if (hasPage && hasRoute && routeFiles.length === 2) { |
|
return; |
|
} |
|
|
|
throw new DuplicateRouteError( |
|
`Duplicate route ${routePath}`, |
|
routeFiles.map((f) => f.filePath) |
|
); |
|
} |
|
}); |
|
} |
|
|
|
function findNearestFile( |
|
dir: string, |
|
fileMap: Map<string, RouteFile> |
|
): RouteFile | undefined { |
|
let currentDir = dir; |
|
while (currentDir !== path.dirname(currentDir)) { |
|
if (fileMap.has(currentDir)) { |
|
return fileMap.get(currentDir); |
|
} |
|
currentDir = path.dirname(currentDir); |
|
} |
|
return undefined; |
|
} |
|
|
|
function buildRouteTree( |
|
files: RouteFile[], |
|
isServer: boolean, |
|
scanOptions: ScanOptions | false |
|
): RouteObjectTemplate[] { |
|
// 모든 파일에 라우트 타입과 우선순위 설정 |
|
files.forEach((file) => { |
|
if (!file.routeType) { |
|
file.routeType = getRouteType(file); |
|
file.priority = calculateRoutePriority(file); |
|
} |
|
}); |
|
|
|
if (scanOptions === false) { |
|
return files |
|
.filter((f) => f.type === 'page' || f.type === 'route') |
|
.map((f) => ({ path: f.routePath })); |
|
} |
|
|
|
const rootPath = path.resolve(options.root); |
|
|
|
// 1. 파일별 맵 생성 |
|
const layoutMap = new Map<string, RouteFile>(); |
|
const errorMap = new Map<string, RouteFile>(); |
|
const fallbackMap = new Map<string, RouteFile>(); |
|
|
|
// 라우트 파일을 우선순위에 따라 정렬 (낮은 우선순위 값이 먼저 매칭) |
|
const pageRouteFiles = files |
|
.filter((f) => f.type === 'page' || f.type === 'route') |
|
.sort((a, b) => (a.priority || 0) - (b.priority || 0)); |
|
|
|
files.forEach((file) => { |
|
if (file.type === 'layout' && scanOptions.layout) { |
|
layoutMap.set(file.dir, file); |
|
} else if (file.type === 'error' && scanOptions.error) { |
|
errorMap.set(file.dir, file); |
|
} else if (file.type === 'fallback' && scanOptions.fallback) { |
|
fallbackMap.set(file.dir, file); |
|
} |
|
}); |
|
|
|
// 2. 디렉토리별 그룹핑 (실제 파일 디렉토리 기준) |
|
const dirMap = new Map< |
|
string, |
|
{ |
|
routes: RouteFile[]; |
|
layout?: RouteFile; |
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any |
|
children: Map<string, any>; |
|
} |
|
>(); |
|
|
|
// 모든 디렉토리 초기화 |
|
const allDirs = new Set<string>(); |
|
files.forEach((file) => { |
|
let currentDir = file.dir; |
|
while ( |
|
currentDir !== path.dirname(currentDir) && |
|
currentDir.startsWith(rootPath) |
|
) { |
|
allDirs.add(currentDir); |
|
currentDir = path.dirname(currentDir); |
|
} |
|
}); |
|
|
|
allDirs.forEach((dir) => { |
|
dirMap.set(dir, { routes: [], children: new Map() }); |
|
}); |
|
|
|
// 라우트 파일들을 디렉토리에 배치 |
|
pageRouteFiles.forEach((file) => { |
|
if ( |
|
(file.type === 'page' && scanOptions.page) || |
|
(file.type === 'route' && scanOptions.route) |
|
) { |
|
const entry = dirMap.get(file.dir); |
|
if (entry) { |
|
entry.routes.push(file); |
|
// 디렉토리 내 라우트들도 우선순위에 따라 정렬 |
|
entry.routes.sort((a, b) => (a.priority || 0) - (b.priority || 0)); |
|
} |
|
} |
|
}); |
|
|
|
// 레이아웃 연결 |
|
dirMap.forEach((entry, dir) => { |
|
const layout = layoutMap.get(dir); |
|
if (layout) { |
|
entry.layout = layout; |
|
} |
|
}); |
|
|
|
// 3. 디렉토리 계층 구조 생성 (bottom-up) |
|
const sortedDirs = [...allDirs].sort( |
|
(a, b) => b.split(dirSplitRx).length - a.split(dirSplitRx).length |
|
); // 깊은 것부터 |
|
|
|
sortedDirs.forEach((dir) => { |
|
const parentDir = path.dirname(dir); |
|
if (parentDir !== dir && dirMap.has(parentDir)) { |
|
const parent = dirMap.get(parentDir)!; |
|
const current = dirMap.get(dir)!; |
|
parent.children.set(dir, current); |
|
} |
|
}); |
|
|
|
// 4. RouteObject 생성 |
|
function createRouteObject(routeFile: RouteFile): RouteObjectTemplate { |
|
const routeObj: RouteObjectTemplate = { |
|
path: routeFile.routePath, |
|
isLazy: options.lazy, |
|
filePath: routeFile.filePath, |
|
dir: routeFile.dir, |
|
}; |
|
|
|
if (routeFile.type === 'page') { |
|
routeObj.componentImport = routeFile.filePath; |
|
if (!isServer) { |
|
// Check if there's a route file in the same directory |
|
const hasRouteFile = pageRouteFiles.some( |
|
(f) => |
|
f.type === 'route' && |
|
f.dir === routeFile.dir && |
|
f.routePath === routeFile.routePath |
|
); |
|
if (!hasRouteFile) { |
|
routeObj.loaderImport = routeFile.filePath; |
|
routeObj.actionImport = routeFile.filePath; |
|
} |
|
} |
|
} |
|
|
|
if (routeFile.type === 'route') { |
|
routeObj.loaderImport = routeFile.filePath; |
|
routeObj.actionImport = routeFile.filePath; |
|
} |
|
|
|
// Add inherited error/fallback |
|
const nearestError = findNearestFile(routeFile.dir, errorMap); |
|
const nearestFallback = findNearestFile(routeFile.dir, fallbackMap); |
|
|
|
if (nearestError) routeObj.errorBoundaryImport = nearestError.filePath; |
|
if (nearestFallback) |
|
routeObj.hydrateFallbackImport = nearestFallback.filePath; |
|
|
|
return routeObj; |
|
} |
|
|
|
function createLayoutObject(layoutFile: RouteFile): RouteObjectTemplate { |
|
const layoutObj: RouteObjectTemplate = { |
|
path: layoutFile.routePath, |
|
layoutImport: layoutFile.filePath, |
|
isLazy: options.lazy, |
|
children: [], |
|
filePath: layoutFile.filePath, |
|
dir: layoutFile.dir, |
|
}; |
|
|
|
// Add inherited error/fallback for layout |
|
const nearestError = findNearestFile(layoutFile.dir, errorMap); |
|
const nearestFallback = findNearestFile(layoutFile.dir, fallbackMap); |
|
|
|
if (nearestError) layoutObj.errorBoundaryImport = nearestError.filePath; |
|
if (nearestFallback) |
|
layoutObj.hydrateFallbackImport = nearestFallback.filePath; |
|
|
|
return layoutObj; |
|
} |
|
|
|
// 5. 최상위 디렉토리부터 트리 구성 |
|
function buildTreeFromDir(dir: string): RouteObjectTemplate[] { |
|
const entry = dirMap.get(dir); |
|
if (!entry) return []; |
|
|
|
const result: RouteObjectTemplate[] = []; |
|
|
|
// 현재 디렉토리의 레이아웃이 있으면 레이아웃을 만들고 자식들을 추가 |
|
if (entry.layout) { |
|
const layoutObj = createLayoutObject(entry.layout); |
|
|
|
// 같은 디렉토리의 페이지들을 index route로 추가 |
|
entry.routes.forEach((route) => { |
|
const routeObj = createRouteObject(route); |
|
routeObj.index = true; |
|
delete routeObj.path; |
|
layoutObj.children!.push(routeObj); |
|
}); |
|
|
|
// 자식 디렉토리들 처리 |
|
entry.children.forEach((_, childDir) => { |
|
const childRoutes = buildTreeFromDir(childDir); |
|
childRoutes.forEach((childRoute) => { |
|
// 자식 라우트의 경로를 상대 경로로 변경 |
|
if (childRoute.path && layoutObj.path) { |
|
let relativePath: string; |
|
if (layoutObj.path === '/') { |
|
// 루트 레이아웃의 경우 맨 앞의 / 제거 |
|
relativePath = childRoute.path.startsWith('/') |
|
? childRoute.path.slice(1) |
|
: childRoute.path; |
|
} else { |
|
// 부모 경로를 제거하여 상대 경로로 변환 |
|
const parentPath = layoutObj.path.endsWith('/') |
|
? layoutObj.path |
|
: layoutObj.path + '/'; |
|
|
|
if (childRoute.path.startsWith(parentPath)) { |
|
relativePath = childRoute.path.slice(parentPath.length); |
|
} else if (childRoute.path === layoutObj.path) { |
|
// 자식이 부모와 완전히 같은 경로인 경우 (shouldn't happen, but handle it) |
|
relativePath = ''; |
|
} else { |
|
// 부모 경로로 시작하지 않는 경우 그대로 사용 |
|
relativePath = childRoute.path; |
|
} |
|
} |
|
childRoute.path = relativePath || undefined; |
|
} |
|
layoutObj.children!.push(childRoute); |
|
}); |
|
}); |
|
|
|
result.push(layoutObj); |
|
} else { |
|
// 레이아웃이 없으면 페이지들을 직접 추가 |
|
entry.routes.forEach((route) => { |
|
result.push(createRouteObject(route)); |
|
}); |
|
|
|
// 자식 디렉토리들도 처리 |
|
entry.children.forEach((_, childDir) => { |
|
result.push(...buildTreeFromDir(childDir)); |
|
}); |
|
} |
|
|
|
return result; |
|
} |
|
|
|
// 루트부터 시작해서 트리 구성 |
|
return buildTreeFromDir(rootPath); |
|
} |
|
|
|
function generateRouteCode(routes: RouteObjectTemplate[]): string { |
|
const importMap = new Map<string, ImportInfo>(); // 파일경로 -> ImportInfo 매핑 |
|
let importCounter = 0; |
|
|
|
// 고급 import 중복 제거 로직 |
|
function getImportReference( |
|
filePath: string, |
|
type: 'default' | 'loader' | 'action' | 'error' | 'fallback' |
|
): string { |
|
if (!importMap.has(filePath)) { |
|
let prefix = 'Component'; |
|
if (filePath.includes('layout.')) prefix = 'Layout'; |
|
else if (filePath.includes('error.')) prefix = 'ErrorBoundary'; |
|
else if (filePath.includes('fallback.')) prefix = 'HydrateFallback'; |
|
else if (filePath.includes('route.')) prefix = 'Route'; |
|
else if (filePath.includes('page.')) prefix = 'Page'; |
|
|
|
importMap.set(filePath, { |
|
importName: `${prefix}${importCounter++}`, |
|
filePath, |
|
}); |
|
} |
|
|
|
const importInfo = importMap.get(filePath)!; |
|
|
|
// namespace import 방식으로 접근 |
|
switch (type) { |
|
case 'loader': |
|
return `${importInfo.importName}.loader`; |
|
case 'action': |
|
return `${importInfo.importName}.action`; |
|
default: |
|
return `${importInfo.importName}.default`; |
|
} |
|
} |
|
|
|
function generateImports(): string[] { |
|
return [...importMap.values()].map( |
|
({ importName, filePath }) => |
|
`import * as ${importName} from ${JSON.stringify(filePath)};` |
|
); |
|
} |
|
|
|
function processRoute(route: RouteObjectTemplate): string { |
|
const parts: string[] = []; |
|
|
|
if (route.path) { |
|
parts.push(`path: ${JSON.stringify(route.path)}`); |
|
} |
|
|
|
if (route.index) { |
|
parts.push('index: true'); |
|
} |
|
|
|
// Layout 처리 |
|
if (route.layoutImport) { |
|
const importName = getImportReference(route.layoutImport, 'default'); |
|
parts.push(`Component: ${importName}`); |
|
} |
|
// Page Component 처리 |
|
else if (route.componentImport) { |
|
if (route.isLazy) { |
|
// Lazy 모드에서는 기존 방식 유지 |
|
const lazyParts: string[] = []; |
|
lazyParts.push(`Component: m.default`); |
|
|
|
if (route.loaderImport) lazyParts.push(`loader: m.loader`); |
|
if (route.actionImport) lazyParts.push(`action: m.action`); |
|
|
|
parts.push( |
|
`lazy: () => import(${JSON.stringify(route.componentImport)}).then(m => ({ ${lazyParts.join(', ')} }))` |
|
); |
|
} else { |
|
// Eager 모드에서 최적화된 import 사용 |
|
const componentName = getImportReference( |
|
route.componentImport, |
|
'default' |
|
); |
|
parts.push(`Component: ${componentName}`); |
|
|
|
if (route.loaderImport === route.componentImport) { |
|
// 같은 파일에서 loader 가져오기 |
|
const loaderName = getImportReference(route.loaderImport, 'loader'); |
|
parts.push(`loader: ${loaderName}`); |
|
} else if (route.loaderImport) { |
|
// 다른 파일에서 loader 가져오기 |
|
const loaderName = getImportReference(route.loaderImport, 'loader'); |
|
parts.push(`loader: ${loaderName}`); |
|
} |
|
|
|
if (route.actionImport === route.componentImport) { |
|
// 같은 파일에서 action 가져오기 |
|
const actionName = getImportReference(route.actionImport, 'action'); |
|
parts.push(`action: ${actionName}`); |
|
} else if (route.actionImport) { |
|
// 다른 파일에서 action 가져오기 |
|
const actionName = getImportReference(route.actionImport, 'action'); |
|
parts.push(`action: ${actionName}`); |
|
} |
|
} |
|
} |
|
// Route-only (component 없이 loader/action만) |
|
else if (route.loaderImport || route.actionImport) { |
|
if (route.isLazy) { |
|
const lazyParts: string[] = []; |
|
if (route.loaderImport) lazyParts.push(`loader: m.loader`); |
|
if (route.actionImport) lazyParts.push(`action: m.action`); |
|
|
|
const importPath = route.loaderImport || route.actionImport; |
|
parts.push( |
|
`lazy: () => import(${JSON.stringify(importPath)}).then(m => ({ ${lazyParts.join(', ')} }))` |
|
); |
|
} else { |
|
if (route.loaderImport) { |
|
const loaderName = getImportReference(route.loaderImport, 'loader'); |
|
parts.push(`loader: ${loaderName}`); |
|
} |
|
if (route.actionImport) { |
|
const actionName = getImportReference(route.actionImport, 'action'); |
|
parts.push(`action: ${actionName}`); |
|
} |
|
} |
|
} |
|
|
|
// Error/Fallback 처리 (항상 eager) |
|
if (route.errorBoundaryImport) { |
|
const importName = getImportReference( |
|
route.errorBoundaryImport, |
|
'error' |
|
); |
|
parts.push(`ErrorBoundary: ${importName}`); |
|
} |
|
if (route.hydrateFallbackImport) { |
|
const importName = getImportReference( |
|
route.hydrateFallbackImport, |
|
'fallback' |
|
); |
|
parts.push(`HydrateFallback: ${importName}`); |
|
} |
|
|
|
if (route.children && route.children.length > 0) { |
|
const childrenCode = route.children.map(processRoute).join(',\n '); |
|
parts.push(`children: [\n ${childrenCode}\n ]`); |
|
} |
|
|
|
return ` {\n ${parts.join(',\n ')}\n }`; |
|
} |
|
|
|
// 라우트 우선순위에 따라 정렬 |
|
const sortedRoutes = [...routes].sort((a, b) => { |
|
// 캐치올 라우트는 항상 마지막으로 |
|
const aIsCatchAll = a.path?.includes('*') || false; |
|
const bIsCatchAll = b.path?.includes('*') || false; |
|
|
|
if (aIsCatchAll && !bIsCatchAll) return 1; |
|
if (!aIsCatchAll && bIsCatchAll) return -1; |
|
|
|
// 그 외에는 일반적인 우선순위 규칙 적용 |
|
return 0; |
|
}); |
|
|
|
// Route 처리 |
|
const routeObjects = sortedRoutes.map(processRoute); |
|
|
|
// 최적화된 import 구문 생성 |
|
const importStatements = generateImports(); |
|
const importsCode = |
|
importStatements.length > 0 ? importStatements.join('\n') + '\n' : ''; |
|
const routesCode = `[\n${routeObjects.join(',\n')}\n]`; |
|
|
|
return `${importsCode}export const routes = ${routesCode};`; |
|
} |
|
|
|
function generateFallbackCode(): string { |
|
return 'export const routes = [];'; |
|
} |
|
|
|
function updateRoutes(): void { |
|
try { |
|
routeFiles = scanFiles(); |
|
|
|
// 모든 파일에 라우트 타입과 우선순위 설정 |
|
routeFiles.forEach((file) => { |
|
file.routeType = getRouteType(file); |
|
file.priority = calculateRoutePriority(file); |
|
}); |
|
|
|
// 충돌하는 경로 로깅 |
|
const routePaths = new Map<string, RouteFile[]>(); |
|
routeFiles.forEach((file) => { |
|
if (file.type === 'page' || file.type === 'route') { |
|
const existing = routePaths.get(file.routePath) || []; |
|
existing.push(file); |
|
routePaths.set(file.routePath, existing); |
|
} |
|
}); |
|
|
|
routePaths.forEach((files, path) => { |
|
if (files.length > 1) { |
|
console.log( |
|
`라우트 경로 '${path}'에 ${files.length}개 파일이 매핑됨:` |
|
); |
|
files.forEach((f) => { |
|
console.log( |
|
` - ${f.filePath} (타입: ${f.routeType}, 우선순위: ${f.priority})` |
|
); |
|
}); |
|
} |
|
}); |
|
|
|
validateRoutes(routeFiles); |
|
|
|
if (options.clientScan || options.clientScan === false) { |
|
const clientRoutes = buildRouteTree( |
|
routeFiles, |
|
false, |
|
options.clientScan |
|
); |
|
clientCode = generateRouteCode(clientRoutes); |
|
} |
|
|
|
if (options.serverScan || options.serverScan === false) { |
|
const serverRoutes = buildRouteTree( |
|
routeFiles, |
|
true, |
|
options.serverScan |
|
); |
|
serverCode = generateRouteCode(serverRoutes); |
|
} |
|
} catch (error) { |
|
console.error('Route generation failed:', error); |
|
clientCode = generateFallbackCode(); |
|
serverCode = generateFallbackCode(); |
|
} |
|
} |
|
|
|
updateRoutes(); |
|
|
|
return [ |
|
{ |
|
name: 'react-routing-plugin', |
|
configureServer(server) { |
|
const rootPath = path.resolve(options.root); |
|
server.watcher.add(rootPath); |
|
|
|
const debounceEvent = Object.assign( |
|
(file: string) => { |
|
if (!file.startsWith(rootPath)) return; |
|
if (debounceEvent.timer) { |
|
clearTimeout(debounceEvent.timer); |
|
} |
|
debounceEvent.timer = setTimeout(() => { |
|
console.log( |
|
`[${new Date().toLocaleString('sv')}]`, |
|
'react-routing-plugin: Updating routes...' |
|
); |
|
updateRoutes(); |
|
|
|
const moduleClient = server.moduleGraph.getModuleById( |
|
'virtual:react-routing-plugin/client' |
|
); |
|
const moduleServer = server.moduleGraph.getModuleById( |
|
'virtual:react-routing-plugin/server' |
|
); |
|
if (moduleClient) { |
|
void server.reloadModule(moduleClient); |
|
} |
|
if (moduleServer) { |
|
void server.reloadModule(moduleServer); |
|
} |
|
|
|
debounceEvent.timer = null; |
|
}, 100); |
|
}, |
|
{ timer: null as NodeJS.Timeout | null } |
|
); |
|
|
|
server.watcher.on('change', debounceEvent); |
|
}, |
|
|
|
resolveId(id) { |
|
if (id === 'virtual:react-routing-plugin/client') { |
|
return id; |
|
} |
|
if (id === 'virtual:react-routing-plugin/server') { |
|
return id; |
|
} |
|
}, |
|
|
|
load(id) { |
|
if (id === 'virtual:react-routing-plugin/client') { |
|
return clientCode; |
|
} |
|
if (id === 'virtual:react-routing-plugin/server') { |
|
return serverCode; |
|
} |
|
}, |
|
|
|
buildStart() { |
|
updateRoutes(); |
|
}, |
|
}, |
|
]; |
|
} |