Skip to content

Commit

Permalink
feat: remix optional segments (#4706)
Browse files Browse the repository at this point in the history
* feat: transform optional routes from remix to react router

* Add to contributors

* Add changeset

* fix(optional-segments): fix escaping of parenthesis

* small function fix

* Update packages/remix-dev/__tests__/routesConvention-test.ts

Co-authored-by: Pedro Cattori <pcattori@gmail.com>

Co-authored-by: Pedro Cattori <pcattori@gmail.com>
  • Loading branch information
lordofthecactus and pcattori committed Dec 9, 2022
1 parent 07c7dca commit d7efede
Show file tree
Hide file tree
Showing 4 changed files with 148 additions and 2 deletions.
25 changes: 25 additions & 0 deletions .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
```
1 change: 1 addition & 0 deletions contributors.yml
Expand Up @@ -258,6 +258,7 @@
- lili21
- lionotm
- liranm
- lordofthecactus
- lpsinger
- lswest
- lucasdibz
Expand Down
56 changes: 56 additions & 0 deletions packages/remix-dev/__tests__/routesConvention-test.ts
Expand Up @@ -41,13 +41,69 @@ 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) {
it(`"${input}" -> "${expected}"`, () => {
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");
});
});
});
});

Expand Down
68 changes: 66 additions & 2 deletions packages/remix-dev/config/routesConvention.ts
Expand Up @@ -113,12 +113,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);
Expand All @@ -140,8 +145,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;
Expand All @@ -157,18 +188,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;
}

Expand All @@ -180,6 +233,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;
}
Expand All @@ -191,6 +249,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;
}

Expand Down

0 comments on commit d7efede

Please sign in to comment.