From 7e579db01342729f69ad83b70d22d5eb323a2388 Mon Sep 17 00:00:00 2001 From: Logan McAnsh Date: Tue, 20 Dec 2022 16:39:10 -0500 Subject: [PATCH] chore(flat-routes): add support for optional and escape route segments Signed-off-by: Logan McAnsh --- .../remix-dev/__tests__/flat-routes-test.ts | 13 +- packages/remix-dev/config/flat-routes.ts | 320 ++++++++++-------- packages/remix-dev/config/routesConvention.ts | 150 ++++---- 3 files changed, 248 insertions(+), 235 deletions(-) diff --git a/packages/remix-dev/__tests__/flat-routes-test.ts b/packages/remix-dev/__tests__/flat-routes-test.ts index acf4196e29f..6d4ba1c4c6b 100644 --- a/packages/remix-dev/__tests__/flat-routes-test.ts +++ b/packages/remix-dev/__tests__/flat-routes-test.ts @@ -1,4 +1,8 @@ -import { createRoutePath } from "../config/flat-routes"; +import { + createRoutePath, + getRouteSegments, + isIndexRoute, +} from "../config/flat-routes"; describe("createRoutePath", () => { describe("creates proper route paths", () => { @@ -10,6 +14,7 @@ describe("createRoutePath", () => { ["_auth", undefined], ["_landing.about", "about"], ["_landing._index", undefined], + ["_landing.index", "index"], ["_landing", undefined], ["app.calendar.$day", "app/calendar/:day"], ["app.calendar._index", "app/calendar"], @@ -94,7 +99,11 @@ describe("createRoutePath", () => { for (let [input, expected] of tests) { it(`"${input}" -> "${expected}"`, () => { - expect(createRoutePath(input)).toBe(expected); + let segments = getRouteSegments(input); + let index = isIndexRoute(input); + let result = createRoutePath(segments, index); + let expectedPath = expected === undefined ? undefined : "/" + expected; + expect(result).toBe(expectedPath); }); } }); diff --git a/packages/remix-dev/config/flat-routes.ts b/packages/remix-dev/config/flat-routes.ts index 68f76d86c7e..3dd069c973c 100644 --- a/packages/remix-dev/config/flat-routes.ts +++ b/packages/remix-dev/config/flat-routes.ts @@ -1,32 +1,32 @@ -import fs from "node:fs"; -import path from "node:path"; +import * as fs from "fs"; +import * as path from "path"; import minimatch from "minimatch"; import { createRouteId, defineRoutes } from "./routes"; import type { RouteManifest, DefineRouteFunction } from "./routes"; import { - isCloseEscapeSequence, - isCloseOptionalSegment, - isNewEscapeSequence, - isNewOptionalSegment, + escapeEnd, + escapeStart, isRouteModuleFile, optionalEnd, optionalStart, paramPrefixChar, } from "./routesConvention"; -import invariant from "../invariant"; -import type { ConfigRoute } from "./routes"; -interface RouteInfo extends ConfigRoute { +type RouteInfo = { + id: string; + path: string; + file: string; name: string; segments: string[]; -} + parentId?: string; + index?: boolean; + caseSensitive?: boolean; +}; -export type VisitFilesFunction = ( - dir: string, - visitor: (file: string) => void, - baseDir?: string -) => void; +export type FlatRoutesOptions = { + routeRegex?: RegExp; +}; export function flatRoutes( appDir: string, @@ -37,9 +37,10 @@ export function flatRoutes( visitFiles(path.join(appDir, "routes"), (file) => { if ( - ignoredFilePatterns?.some((pattern) => { - return minimatch(file, pattern, { dot: true }); - }) + ignoredFilePatterns && + ignoredFilePatterns.some((pattern) => + minimatch(file, pattern, { dot: true }) + ) ) { return; } @@ -53,13 +54,14 @@ export function flatRoutes( }); // update parentIds for all routes - Array.from(routeMap.values()).forEach((routeInfo) => { + for (let routeInfo of routeMap.values()) { let parentId = findParentRouteId(routeInfo, nameMap); routeInfo.parentId = parentId; - }); + } + let uniqueRoutes = new Map(); - // then, recurse through all routes using the public defineRoutes() API + // Then, recurse through all routes using the public defineRoutes() API function defineNestedRoutes( defineRoute: DefineRouteFunction, parentId?: string @@ -68,10 +70,9 @@ export function flatRoutes( (routeInfo) => routeInfo.parentId === parentId ); let parentRoute = parentId ? routeMap.get(parentId) : undefined; - let parentRoutePath = parentRoute?.path ?? ""; + let parentRoutePath = parentRoute?.path ?? "/"; for (let childRoute of childRoutes) { - let routePath = childRoute.path?.slice(parentRoutePath.length) ?? ""; - + let routePath = childRoute?.path?.slice(parentRoutePath.length) ?? ""; // remove leading slash if (routePath.startsWith("/")) { routePath = routePath.slice(1); @@ -105,174 +106,201 @@ export function flatRoutes( ); } - let child = routeMap.get(childRoute.id); - invariant(child, "route not found in route map"); - - defineRoute(routePath, child.file, { index: true }); + defineRoute(routePath, routeMap.get(childRoute.id!)!.file, { + index: true, + }); } else { - let child = routeMap.get(childRoute.id); - invariant(child, "route not found in route map"); - - defineRoute(routePath, child.file, () => { + defineRoute(routePath, routeMap.get(childRoute.id!)!.file, () => { defineNestedRoutes(defineRoute, childRoute.id); }); } } } + let routes = defineRoutes(defineNestedRoutes); + return routes; +} - return defineRoutes(defineNestedRoutes); +const indexRouteRegex = /((^|[.])(_index))(\/[^/]+)?$|(\/_?index\/)/; +export function isIndexRoute(routeId: string): boolean { + return indexRouteRegex.test(routeId); } -export function getRouteInfo(routeDir: string, file: string): RouteInfo { +export function getRouteInfo(routeDir: string, file: string) { let filePath = path.join(routeDir, file); let routeId = createRouteId(filePath); let routeIdWithoutRoutes = routeId.slice(routeDir.length + 1); - let routePath = createRoutePath(routeIdWithoutRoutes); - - return { + let index = isIndexRoute(routeIdWithoutRoutes); + let routeSegments = getRouteSegments(routeIdWithoutRoutes); + let routePath = createRoutePath(routeSegments, index); + let routeInfo = { id: routeId, path: routePath!, file: filePath, - name: routePath || routeIdWithoutRoutes, - segments: routePath?.split("/"), - index: routePath?.endsWith("_index"), + name: routeSegments.join("/"), + segments: routeSegments, + index, }; + + return routeInfo; } -function findParentRouteId( - routeInfo: RouteInfo, - nameMap: Map -): string | undefined { - let parentName = routeInfo.segments.slice(0, -1).join("/"); - while (parentName) { - let parentRoute = nameMap.get(parentName); - if (parentRoute) return parentRoute.id; - parentName = parentName.substring(0, parentName.lastIndexOf("/")); +function handleEscapedSegment(segment: string) { + let matches = segment.match(/\[(.*?)\]/g); + + if (!matches) return segment; + + for (let match of matches) { + segment = segment.replace(match, match.slice(1, -1)); } - return undefined; + + return segment; } -export function createRoutePath(partialRouteId: string): string | undefined { - let result = ""; - let rawSegmentBuffer = ""; - - let inEscapeSequence = 0; - let inOptionalSegment = 0; - let optionalSegmentIndex = null; - let skipSegment = false; - for (let i = 0; i < partialRouteId.length; i++) { - let char = partialRouteId.charAt(i); - let prevChar = i > 0 ? partialRouteId.charAt(i - 1) : undefined; - let nextChar = - i < partialRouteId.length - 1 ? partialRouteId.charAt(i + 1) : undefined; - - if (skipSegment) { - if (isSegmentSeparator(char)) { - skipSegment = false; - } - continue; - } +function handleSplatOrParamSegment(segment: string) { + console.log("handleSplatOrParam", segment); - if (isNewEscapeSequence(inEscapeSequence, char, prevChar)) { - inEscapeSequence++; - continue; + if (segment.startsWith(paramPrefixChar)) { + if (segment === "$?") return segment; + if (segment === paramPrefixChar) { + return "*"; } - if (isCloseEscapeSequence(inEscapeSequence, char, nextChar)) { - inEscapeSequence--; - continue; - } + return `:${segment.slice(1)}`; + } - if ( - isNewOptionalSegment(char, prevChar, inOptionalSegment, inEscapeSequence) - ) { - inOptionalSegment++; - optionalSegmentIndex = result.length; - result += optionalStart; - continue; - } + return segment; +} - if ( - isCloseOptionalSegment( - char, - nextChar, - inOptionalSegment, - inEscapeSequence - ) - ) { - if (optionalSegmentIndex !== null) { - result = - result.slice(0, optionalSegmentIndex) + - result.slice(optionalSegmentIndex + 1); - } - optionalSegmentIndex = null; - inOptionalSegment--; - result += "?"; - continue; - } +function handleOptionalSegment(segment: string) { + let optional = segment.slice(1, -1); - if (inEscapeSequence) { - result += char; - continue; - } + if (optional.startsWith(paramPrefixChar)) { + return `:${optional.slice(1)}?`; + } - if (isSegmentSeparator(char)) { - if (rawSegmentBuffer === "_index" && result.endsWith("index")) { - result = result.replace(/\/?index$/, ""); - } else { - result += "/"; - } + return optional + "?"; +} - rawSegmentBuffer = ""; - inOptionalSegment = 0; - optionalSegmentIndex = null; +// create full path starting with / +export function createRoutePath( + routeSegments: string[], + index: boolean +): string | undefined { + let result = ""; + + if (index) { + // remove index segment + routeSegments = routeSegments.slice(0, -1); + } + + for (let segment of routeSegments) { + // skip pathless layout segments + if (segment.startsWith("_")) { continue; } - if (char === "_" && isSegmentSeparator(nextChar)) { - char = ""; + // remove trailing slash + if (segment.endsWith("_")) { + segment = segment.slice(0, -1); } - // isStartOfLayoutSegment - if (char === "_" && !rawSegmentBuffer) { - skipSegment = true; - continue; + // handle optional segments: `(segment)` => `segment?` + if (segment.startsWith(optionalStart) && segment.endsWith(optionalEnd)) { + let escaped = handleEscapedSegment(segment); + let optional = handleOptionalSegment(escaped); + let param = handleSplatOrParamSegment(optional); + result += `/${param}`; } - rawSegmentBuffer += char; - - if (char === paramPrefixChar) { - if (nextChar === optionalEnd) { - throw new Error( - `Invalid route path: ${partialRouteId}. Splat route $ is already optional` - ); - } - result += typeof nextChar === "undefined" ? "*" : ":"; - continue; + // handle escape segments: `[se[g]ment]` => `segment` + else if (segment.includes(escapeStart) && segment.includes(escapeEnd)) { + let escaped = handleEscapedSegment(segment); + let param = handleSplatOrParamSegment(escaped); + result += `/${param}`; } - result += char; + // handle param segments: `$` => `*`, `$id` => `:id` + else if (segment.startsWith(paramPrefixChar)) { + result += `/${handleSplatOrParamSegment(segment)}`; + } else { + result += `/${segment}`; + } } - if (rawSegmentBuffer === "_index" && result.endsWith("index")) { - result = result.replace(/\/?index$/, ""); + return result || undefined; +} + +function findParentRouteId( + routeInfo: RouteInfo, + nameMap: Map +): string | undefined { + let parentName = routeInfo.segments.slice(0, -1).join("/"); + while (parentName) { + if (nameMap.has(parentName)) { + return nameMap.get(parentName)!.id; + } + parentName = parentName.substring(0, parentName.lastIndexOf("/")); } + return undefined; +} - if (rawSegmentBuffer === "_index" && result.endsWith("index?")) { - throw new Error( - `Invalid route path: ${partialRouteId}. Make index route optional by using (index)` - ); - } else if (rawSegmentBuffer === "_index" && result.endsWith("_index?")) { - throw new Error( - `Invalid route path: ${partialRouteId}. Make index route optional by using (index)` - ); +export function getRouteSegments(name: string) { + let routeSegments: string[] = []; + let index = 0; + let routeSegment = ""; + let state = "START"; + let subState = "NORMAL"; + + let pushRouteSegment = (routeSegment: string) => { + if (routeSegment) { + routeSegments.push(routeSegment); + } + }; + + while (index < name.length) { + let char = name[index]; + switch (state) { + case "START": + pushRouteSegment(routeSegment); + routeSegment = ""; + state = "PATH"; + continue; // restart without advancing index + case "PATH": + if (isPathSeparator(char) && subState === "NORMAL") { + state = "START"; + break; + } else if (char === optionalStart) { + routeSegment += char; + subState = "OPTIONAL"; + break; + } else if (char === optionalEnd) { + routeSegment += char; + subState = "NORMAL"; + break; + } else if (char === escapeStart) { + routeSegment += char; + subState = "ESCAPE"; + break; + } else if (char === escapeEnd) { + routeSegment += char; + subState = "NORMAL"; + break; + } + routeSegment += char; + break; + } + index++; // advance to next character } - return result.endsWith("/") ? result.slice(0, -1) : result || undefined; + // process remaining segment + pushRouteSegment(routeSegment); + + return routeSegments; } -function isSegmentSeparator(char: string | undefined) { - return char === "/" || char === "." || char === path.win32.sep; +const pathSeparatorRegex = /[/\\.]/; +function isPathSeparator(char: string) { + return pathSeparatorRegex.test(char); } export function visitFiles( diff --git a/packages/remix-dev/config/routesConvention.ts b/packages/remix-dev/config/routesConvention.ts index eca1d0410d4..60ea589ef2e 100644 --- a/packages/remix-dev/config/routesConvention.ts +++ b/packages/remix-dev/config/routesConvention.ts @@ -5,17 +5,10 @@ import minimatch from "minimatch"; import type { RouteManifest, DefineRouteFunction } from "./routes"; import { defineRoutes, createRouteId } from "./routes"; -export const routeModuleExts = new Set([ - ".js", - ".jsx", - ".ts", - ".tsx", - ".md", - ".mdx", -]); +const routeModuleExts = [".js", ".jsx", ".ts", ".tsx", ".md", ".mdx"]; export function isRouteModuleFile(filename: string): boolean { - return routeModuleExts.has(path.extname(filename)); + return routeModuleExts.includes(path.extname(filename)); } /** @@ -117,12 +110,11 @@ export function defineConventionalRoutes( return defineRoutes(defineNestedRoutes); } -export let paramPrefixChar = "$" as const; -export let escapeStart = "[" as const; -export let escapeEnd = "]" as const; +let escapeStart = "["; +let escapeEnd = "]"; -export let optionalStart = "(" as const; -export let optionalEnd = ")" as const; +let optionalStart = "("; +let optionalEnd = ")"; // TODO: Cleanup and write some tests for this function export function createRoutePath(partialRouteId: string): string | undefined { @@ -135,44 +127,75 @@ export function createRoutePath(partialRouteId: string): string | undefined { let skipSegment = false; for (let i = 0; i < partialRouteId.length; i++) { let char = partialRouteId.charAt(i); - let prevChar = i > 0 ? partialRouteId.charAt(i - 1) : undefined; + let lastChar = i > 0 ? partialRouteId.charAt(i - 1) : undefined; let nextChar = i < partialRouteId.length - 1 ? partialRouteId.charAt(i + 1) : undefined; + function isNewEscapeSequence() { + return ( + !inEscapeSequence && char === escapeStart && lastChar !== escapeStart + ); + } + + function isCloseEscapeSequence() { + return inEscapeSequence && char === escapeEnd && nextChar !== escapeEnd; + } + + function isStartOfLayoutSegment() { + return char === "_" && nextChar === "_" && !rawSegmentBuffer; + } + + function isSegmentSeparator(checkChar = char) { + return ( + checkChar === "/" || checkChar === "." || checkChar === path.win32.sep + ); + } + + function isNewOptionalSegment() { + return ( + char === optionalStart && + lastChar !== optionalStart && + (isSegmentSeparator(lastChar) || lastChar === undefined) && + !inOptionalSegment && + !inEscapeSequence + ); + } + + function isCloseOptionalSegment() { + return ( + char === optionalEnd && + nextChar !== optionalEnd && + (isSegmentSeparator(nextChar) || nextChar === undefined) && + inOptionalSegment && + !inEscapeSequence + ); + } + if (skipSegment) { - if (isSegmentSeparator(char)) { + if (isSegmentSeparator()) { skipSegment = false; } continue; } - if (isNewEscapeSequence(inEscapeSequence, char, prevChar)) { + if (isNewEscapeSequence()) { inEscapeSequence++; continue; } - if (isCloseEscapeSequence(inEscapeSequence, char, nextChar)) { + if (isCloseEscapeSequence()) { inEscapeSequence--; continue; } - if ( - isNewOptionalSegment(char, prevChar, inOptionalSegment, inEscapeSequence) - ) { + if (isNewOptionalSegment()) { inOptionalSegment++; optionalSegmentIndex = result.length; - result += optionalStart; + result += "("; continue; } - if ( - isCloseOptionalSegment( - char, - nextChar, - inOptionalSegment, - inEscapeSequence - ) - ) { + if (isCloseOptionalSegment()) { if (optionalSegmentIndex !== null) { result = result.slice(0, optionalSegmentIndex) + @@ -189,7 +212,7 @@ export function createRoutePath(partialRouteId: string): string | undefined { continue; } - if (isSegmentSeparator(char)) { + if (isSegmentSeparator()) { if (rawSegmentBuffer === "index" && result.endsWith("index")) { result = result.replace(/\/?index$/, ""); } else { @@ -202,16 +225,15 @@ export function createRoutePath(partialRouteId: string): string | undefined { continue; } - // isStartOfLayoutSegment - if (char === "_" && nextChar === "_" && !rawSegmentBuffer) { + if (isStartOfLayoutSegment()) { skipSegment = true; continue; } rawSegmentBuffer += char; - if (char === paramPrefixChar) { - if (nextChar === optionalEnd) { + if (char === "$") { + if (nextChar === ")") { throw new Error( `Invalid route path: ${partialRouteId}. Splat route $ is already optional` ); @@ -229,64 +251,13 @@ export function createRoutePath(partialRouteId: string): string | undefined { if (rawSegmentBuffer === "index" && result.endsWith("index?")) { throw new Error( - `Invalid route path: ${partialRouteId}. Make index route optional by using (index)` + `Invalid route path: ${partialRouteId}. Make index route optional by using [index]` ); } return result || undefined; } -export function isNewEscapeSequence( - inEscapeSequence: number, - char: string, - prevChar: string | undefined -) { - return !inEscapeSequence && char === escapeStart && prevChar !== escapeStart; -} - -export function isCloseEscapeSequence( - inEscapeSequence: number, - char: string, - nextChar: string | undefined -) { - return inEscapeSequence && char === escapeEnd && nextChar !== escapeEnd; -} - -export function isSegmentSeparator(checkChar: string | undefined) { - if (!checkChar) return false; - return ["/", ".", path.win32.sep].includes(checkChar); -} - -export function isNewOptionalSegment( - char: string, - prevChar: string | undefined, - inOptionalSegment: number, - inEscapeSequence: number -) { - return ( - char === optionalStart && - prevChar !== optionalStart && - (isSegmentSeparator(prevChar) || prevChar === undefined) && - !inOptionalSegment && - !inEscapeSequence - ); -} - -export function isCloseOptionalSegment( - char: string, - nextChar: string | undefined, - inOptionalSegment: number, - inEscapeSequence: number -) { - return ( - char === optionalEnd && - nextChar !== optionalEnd && - (isSegmentSeparator(nextChar) || nextChar === undefined) && - inOptionalSegment && - !inEscapeSequence - ); -} - function getParentRouteIds( routeIds: string[] ): Record { @@ -319,3 +290,8 @@ function visitFiles( } } } + +/* +eslint + no-loop-func: "off", +*/