Skip to content

Commit 8dfaa48

Browse files
pi0claude
andcommitted
feat: support hyphens in route parameter names
Allow `:test-id` style params by updating `\w+` → `[\w-]+` in all param name regex patterns across add, regexp, and utils modules. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 0f86945 commit 8dfaa48

File tree

4 files changed

+50
-8
lines changed

4 files changed

+50
-8
lines changed

src/operations/_utils.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,14 +20,14 @@ export function decodeEscaped(segment: string): string {
2020

2121
export function expandModifiers(segments: string[]): string[] | undefined {
2222
for (let i = 0; i < segments.length; i++) {
23-
const m = segments[i].match(/^(.*:\w+(?:\([^)]*\))?)([?+*])$/);
23+
const m = segments[i].match(/^(.*:[\w-]+(?:\([^)]*\))?)([?+*])$/);
2424
if (!m) continue;
2525
const pre = segments.slice(0, i);
2626
const suf = segments.slice(i + 1);
2727
if (m[2] === "?") {
2828
return ["/" + pre.concat(m[1]).concat(suf).join("/"), "/" + pre.concat(suf).join("/")];
2929
}
30-
const name = m[1].match(/:(\w+)/)?.[1] || "_";
30+
const name = m[1].match(/:([\w-]+)/)?.[1] || "_";
3131
const wc = "/" + [...pre, `**:${name}`, ...suf].join("/");
3232
const without = "/" + [...pre, ...suf].join("/");
3333
return m[2] === "+" ? [wc] : [wc, without];

src/operations/add.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ export function addRoute<T>(
8080
segment.includes(":", 1) ||
8181
segment.includes("(") ||
8282
hasSegmentWildcard(segment) ||
83-
!/^:\w+$/.test(segment)
83+
!/^:[\w-]+$/.test(segment)
8484
) {
8585
const [regexp, nextIndex] = getParamRegexp(segment, _unnamedParamIndex);
8686
_unnamedParamIndex = nextIndex;
@@ -153,7 +153,7 @@ function getParamRegexp(segment: string, unnamedStart = 0): [RegExp, number] {
153153
[_s, _i] = replaceSegmentWildcards(_s, _i);
154154

155155
const regex = _s
156-
.replace(/:(\w+)(?:\(([^)]*)\))?/g, (_, id, p) => `(?<${id}>${p || "[^/]+"})`)
156+
.replace(/:([\w-]+)(?:\(([^)]*)\))?/g, (_, id, p) => `(?<${id}>${p || "[^/]+"})`)
157157
.replace(/\((?![?<])/g, () => `(?<${toUnnamedGroupKey(_i++)}>`)
158158
.replace(/\./g, "\\.")
159159
.replace(/\uFFFE(.)/g, (_, c) => (/[.*+?^${}()|[\]\\]/.test(c) ? `\\${c}` : c));

src/regexp.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,15 +33,15 @@ function _routeToRegExp(route: string): RegExp {
3333
/(^|[^\\])\(/.test(segment) ||
3434
hasSegmentWildcard(segment)
3535
) {
36-
const modMatch = segment.match(/^(.*:\w+(?:\([^)]*\))?)([?+*])$/);
36+
const modMatch = segment.match(/^(.*:[\w-]+(?:\([^)]*\))?)([?+*])$/);
3737
if (modMatch) {
3838
const [, base, mod] = modMatch;
39-
const name = base.match(/:(\w+)/)?.[1] || `_${idCtr++}`;
39+
const name = base.match(/:([\w-]+)/)?.[1] || `_${idCtr++}`;
4040

4141
if (mod === "?") {
4242
const inner = base
4343
.replace(
44-
/:(\w+)(?:\(([^)]*)\))?/g,
44+
/:([\w-]+)(?:\(([^)]*)\))?/g,
4545
(_, id, pattern) => `(?<${id}>${pattern || "[^/]+"})`,
4646
)
4747
.replace(/\./g, "\\.");
@@ -91,7 +91,7 @@ function _routeToRegExp(route: string): RegExp {
9191
resolveEscapePlaceholders(
9292
dynamicSegment
9393
.replace(
94-
/:(\w+)(?:\(([^)]*)\))?/g,
94+
/:([\w-]+)(?:\(([^)]*)\))?/g,
9595
(_, id, pattern) => `(?<${id}>${pattern || "[^/]+"})`,
9696
)
9797
.replace(/(^|[^\\])\((?![?<])/g, (_, p) => `${p}(?<${toRegExpUnnamedKey(idCtr++)}>`)

test/find.test.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,3 +169,45 @@ describe("route matching", () => {
169169
expect(findRoute(router, "GET", "/test")).toBeUndefined();
170170
});
171171
});
172+
173+
describe("hyphenated param names", () => {
174+
const router = createRouter([
175+
"/users/:user-id",
176+
"/users/:user-id/posts/:post-id",
177+
"/items/:item-name/details",
178+
]);
179+
180+
const compiledLookup = compileRouter(router);
181+
182+
const lookups = [
183+
{
184+
name: "findRoute",
185+
match: (method: string, path: string) => findRoute(router, method, path),
186+
},
187+
{
188+
name: "compiledLookup",
189+
match: (method: string, path: string) => compiledLookup(method, path),
190+
},
191+
];
192+
193+
for (const { name, match } of lookups) {
194+
it(`match hyphenated params with ${name}`, () => {
195+
expect(match("GET", "/users/123")).toMatchObject({
196+
data: { path: "/users/:user-id" },
197+
params: { "user-id": "123" },
198+
});
199+
expect(match("GET", "/users/abc/posts/456")).toMatchObject({
200+
data: { path: "/users/:user-id/posts/:post-id" },
201+
params: { "user-id": "abc", "post-id": "456" },
202+
});
203+
expect(match("GET", "/items/widget/details")).toMatchObject({
204+
data: { path: "/items/:item-name/details" },
205+
params: { "item-name": "widget" },
206+
});
207+
// Hyphenated param should still be single-segment
208+
expect(match("GET", "/users/foo/bar")).not.toMatchObject({
209+
data: { path: "/users/:user-id" },
210+
});
211+
});
212+
}
213+
});

0 commit comments

Comments
 (0)