Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: remix optional segments #4706

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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 @@ -253,6 +253,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 @@ -36,12 +36,68 @@ 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?/$?/*",
pcattori marked this conversation as resolved.
Show resolved Hide resolved
],
[
"($[$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");
});
});
});
});
68 changes: 66 additions & 2 deletions packages/remix-dev/config/routesConvention.ts
Expand Up @@ -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);
Expand All @@ -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;
Expand All @@ -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;
}

Expand All @@ -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;
}
Expand All @@ -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;
}

Expand Down