diff --git a/.changeset/moody-pants-own.md b/.changeset/moody-pants-own.md new file mode 100644 index 00000000000..7b9579963cd --- /dev/null +++ b/.changeset/moody-pants-own.md @@ -0,0 +1,25 @@ +--- +"@remix-run/dev": minor +--- + +feat: remix optional segments + +Allows for the creation of optional route segments by using parenthesis. For example: +Creating the following file routes in remix `/($lang)/about` +this will match the following routes +``` +/en/about +/fr/about +/about +``` + +helpful for optional language paths. + +Another example `/(one)/($two)/(three).($four)` file routing would match +``` +/ +/one +/one/param1 +/one/param1/three +/one/param1/three/param2 +``` diff --git a/contributors.yml b/contributors.yml index bc025058fe8..1227cd93c7d 100644 --- a/contributors.yml +++ b/contributors.yml @@ -253,6 +253,7 @@ - lili21 - lionotm - liranm +- lordofthecactus - lpsinger - lswest - lucasdibz diff --git a/packages/remix-dev/__tests__/routesConvention-test.ts b/packages/remix-dev/__tests__/routesConvention-test.ts index 1497246ce16..9ea2fdac07b 100644 --- a/packages/remix-dev/__tests__/routesConvention-test.ts +++ b/packages/remix-dev/__tests__/routesConvention-test.ts @@ -36,6 +36,44 @@ describe("createRoutePath", () => { ["[index]", "index"], ["test/inde[x]", "test/index"], ["[i]ndex/[[].[[]]", "index/[/[]"], + + // Optional segment routes + ["(routes)/$", "routes?/*"], + ["(routes)/(sub)/$", "routes?/sub?/*"], + ["(routes).(sub)/$", "routes?/sub?/*"], + ["(routes)/($slug)", "routes?/:slug?"], + ["(routes)/sub/($slug)", "routes?/sub/:slug?"], + ["(routes).sub/($slug)", "routes?/sub/:slug?"], + ["(nested)/$", "nested?/*"], + ["(flat).$", "flat?/*"], + ["($slug)", ":slug?"], + ["(nested)/($slug)", "nested?/:slug?"], + ["(flat).($slug)", "flat?/:slug?"], + ["flat.(sub)", "flat/sub?"], + ["__layout/(test)", "test?"], + ["__layout.(test)", "test?"], + ["__layout/($slug)", ":slug?"], + ["(nested)/__layout/($slug)", "nested?/:slug?"], + ["($slug[.]json)", ":slug.json?"], + ["(sub)/([sitemap.xml])", "sub?/sitemap.xml?"], + ["(sub)/[(sitemap.xml)]", "sub?/(sitemap.xml)"], + ["(posts)/($slug)/([image.jpg])", "posts?/:slug?/image.jpg?"], + [ + "($[$dollabills]).([.]lol)[/](what)/([$]).$", + ":$dollabills?/.lol)/(what?/$?/*", + ], + [ + "($[$dollabills]).([.]lol)/(what)/([$]).($up)", + ":$dollabills?/.lol?/what?/$?/:up?", + ], + ["(sub).([[])", "sub?/[?"], + ["(sub).(])", "sub?/]?"], + ["(sub).([[]])", "sub?/[]?"], + ["(sub).([[])", "sub?/[?"], + ["(beef])", "beef]?"], + ["([index])", "index?"], + ["(test)/(inde[x])", "test?/index?"], + ["([i]ndex)/([[]).([[]])", "index?/[?/[]?"], ]; for (let [input, expected] of tests) { @@ -43,5 +81,23 @@ describe("createRoutePath", () => { expect(createRoutePath(input)).toBe(expected); }); } + + describe("optional segments", () => { + it("will only work when starting and ending a segment with parenthesis", () => { + let [input, expected] = ["(routes.sub)/$", "(routes/sub)/*"]; + expect(createRoutePath(input)).toBe(expected); + }); + + it("throws error on optional to splat routes", () => { + expect(() => createRoutePath("(routes)/($)")).toThrow("Splat"); + expect(() => createRoutePath("($)")).toThrow("Splat"); + }); + + it("throws errors on optional index without brackets routes", () => { + expect(() => createRoutePath("(nested)/(index)")).toThrow("index"); + expect(() => createRoutePath("(flat).(index)")).toThrow("index"); + expect(() => createRoutePath("(index)")).toThrow("index"); + }); + }); }); }); diff --git a/packages/remix-dev/config/routesConvention.ts b/packages/remix-dev/config/routesConvention.ts index 4fb5fc3211e..851d3c62f1b 100644 --- a/packages/remix-dev/config/routesConvention.ts +++ b/packages/remix-dev/config/routesConvention.ts @@ -112,12 +112,17 @@ export function defineConventionalRoutes( let escapeStart = "["; let escapeEnd = "]"; +let optionalStart = "("; +let optionalEnd = ")"; + // TODO: Cleanup and write some tests for this function 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); @@ -139,8 +144,34 @@ export function createRoutePath(partialRouteId: string): string | undefined { 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 (char === "/" || char === "." || char === path.win32.sep) { + if (isSegmentSeparator()) { skipSegment = false; } continue; @@ -156,18 +187,40 @@ export function createRoutePath(partialRouteId: string): string | undefined { continue; } + if (isNewOptionalSegment()) { + inOptionalSegment++; + optionalSegmentIndex = result.length; + result += "("; + continue; + } + + if (isCloseOptionalSegment()) { + if (optionalSegmentIndex !== null) { + result = + result.slice(0, optionalSegmentIndex) + + result.slice(optionalSegmentIndex + 1); + } + optionalSegmentIndex = null; + inOptionalSegment--; + result += "?"; + continue; + } + if (inEscapeSequence) { result += char; continue; } - if (char === "/" || char === path.win32.sep || char === ".") { + if (isSegmentSeparator()) { if (rawSegmentBuffer === "index" && result.endsWith("index")) { result = result.replace(/\/?index$/, ""); } else { result += "/"; } + rawSegmentBuffer = ""; + inOptionalSegment = 0; + optionalSegmentIndex = null; continue; } @@ -179,6 +232,11 @@ export function createRoutePath(partialRouteId: string): string | undefined { rawSegmentBuffer += char; if (char === "$") { + if (nextChar === ")") { + throw new Error( + `Invalid route path: ${partialRouteId}. Splat route $ is already optional` + ); + } result += typeof nextChar === "undefined" ? "*" : ":"; continue; } @@ -190,6 +248,12 @@ export function createRoutePath(partialRouteId: string): string | undefined { result = result.replace(/\/?index$/, ""); } + if (rawSegmentBuffer === "index" && result.endsWith("index?")) { + throw new Error( + `Invalid route path: ${partialRouteId}. Make index route optional by using [index]` + ); + } + return result || undefined; }