Skip to content

feat!: url pattern compatibility - #178

Merged
pi0 merged 19 commits into
mainfrom
feat/url-pattern-compat
Mar 9, 2026
Merged

feat!: url pattern compatibility#178
pi0 merged 19 commits into
mainfrom
feat/url-pattern-compat

Conversation

@pi0

@pi0 pi0 commented Feb 27, 2026

Copy link
Copy Markdown
Member

Summary

URLPattern-compatible route pattern syntax with comprehensive WPT test coverage.

Pattern syntax

  • Named regex constraints:id(\d+), :ext(png|jpg|gif)
  • Unnamed regex groups(\d+), (png|jpg|gif) (auto-indexed as "0", "1", ...)
  • Wildcard * unnamed captures/*.png, /file-*-*.png (mid-segment wildcards)
  • Modifiers:name? (optional), :name+ (one-or-more), :name* (zero-or-more)
  • Regex + modifier combos:id(\d+)?, :id(\d+)+, :id(\d+)*
  • Param coexistence — regex-constrained and unconstrained params on same node
  • Non-capturing groups{...} and {...}? (e.g. /book{s}?, /blog/:id(\d+){-:title}?)
  • Backslash escaping\:, \*, \(, \), \{, \} for literal characters

URLPattern compatibility improvements

  • Backslash escaping in routeToRegExp and router (two-phase placeholder system)
  • Dot-suffixed params/:foo. correctly matches /bar.
  • Modifier anchoring:bar?/:bar*/:bar+ properly bind the preceding /
  • Path normalization — opt-in ./.. resolution via { normalize: true } flag
  • WPT test suite — 362 tests from Web Platform Tests pass (118 skipped for out-of-scope features)

Documented differences from URLPattern

Feature URLPattern rou3
* (single star) Greedy catch-all (.*) across / Single-segment unnamed param ([^/]*)
** (double star) Literal ** Catch-all wildcard (zero or more segments)
(.*) in segment Greedy match across / Segment-scoped (does not cross /)
{...}+ / {...}* groups Cross-segment group repetition Single-segment only
Path normalization Always resolves ./.. Opt-in with { normalize: true }
Case sensitivity Can be case-insensitive Always case-sensitive
Non-/-prefixed paths Supported Paths must start with /
Unicode param names Unicode identifiers ASCII \w only
Percent-encoding Normalizes %xx No decoding

Breaking change

Unnamed captures now use URLPattern-style numeric keys:

  • Before: _0, _1, ...
  • After: "0", "1", ...

Applies to unnamed regex groups and * captures in findRoute() / findAllRoutes() / compiled matchers. Type-level inference (InferRouteParams) also uses numeric keys.

routeToRegExp() still uses _0, _1, ... internally because JS RegExp group names cannot be numeric.

Test plan

  • All 567 existing tests pass
  • 362 WPT pathname tests pass (118 skipped for out-of-scope features)
  • Backslash escaping in routes and routeToRegExp
  • Dot-suffixed params (:foo.)
  • Modifier anchoring (:bar?, :bar+, :bar*)
  • Path normalization with { normalize: true }
  • Interpreter and compiled router produce identical results
  • Bundle size within limits (5700 bytes / 2200 gzip)
  • README documents all patterns and URLPattern differences

🤖 Generated with Claude Code

@coderabbitai

coderabbitai Bot commented Feb 27, 2026

Copy link
Copy Markdown

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds group-delimiter expansion, regex-constrained and unnamed parenthesized captures, and URL modifiers (?, +, *) with expansion; updates path-escaping to include parentheses/braces, augments regexp generation, updates router/add/remove flows to expand group delimiters and modifiers, and extends tests and docs.

Changes

Cohort / File(s) Summary
Route registration & parsing
src/operations/add.ts, src/operations/remove.ts
Import and use expandGroupDelimiters to expand {...}-style group delimiters before register/remove; add modifier-expansion (:name?, :name+, :name*) via _expandModifiers; treat segments containing ( as regex parameters and record regex-param mappings; escape additional chars (parentheses and braces) in path escaping.
Group-delimiters helper
src/_group-delimiters.ts
New exported helper `expandGroupDelimiters(path: string): string[]
Regexp generation
src/regexp.ts
Split route->RegExp logic into routeToRegExp and internal _routeToRegExp; add support for modifier-aware segment expansion, inline-pattern handling, named and unnamed captures, and repeat/optional semantics for ?, +, * while preserving existing wildcard behavior.
Tests: regexp, router & bench
test/regexp.test.ts, test/router.test.ts, test/bench/bundle.test.ts
Expanded tests covering regex-constrained params, unnamed capture groups, optional/one-or-more/zero-or-more modifiers, nested/combined patterns, and precedence/coexistence; increased bundle size assertions (unminified and gzip).
Documentation & agents
README.md, AGENTS.md
Add "Route Patterns" section documenting new URLPattern-compatible syntax (named params, wildcards, regex constraints, unnamed groups, modifiers) and document src/_group-delimiters.ts behavior and limitations.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Poem

🐇
I hop through slashes, nibble on parens bright,
A question curls, a plus takes flight,
Braced groups unfold their private door,
Named or shy captures ask for more,
New routes bound out under moonlight ✨

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 28.57% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title 'feat!: url pattern compatibility' is clear and directly related to the main objective of adding URLPattern-compatible pathname route syntax (named regex constraints, modifiers, unnamed groups). It accurately summarizes the primary change.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/url-pattern-compat

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@pi0 pi0 mentioned this pull request Feb 27, 2026
URLPattern-compatible unnamed regex groups are auto-named `_0`, `_1`, etc.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🧹 Nitpick comments (1)
test/router.test.ts (1)

480-536: Add regression tests for multi-unnamed groups and mid-path +/* modifiers.

Please add cases like /path/(\\d+)/(\\w+) (expect _0, _1) and /a/:id+/tail, /a/:id*/tail to lock in route-wide unnamed-group indexing and suffix-preserving modifier behavior.

Also applies to: 538-620

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/router.test.ts` around lines 480 - 536, Add regression tests to cover
multi-unnamed regex groups and mid-path modifiers: add testRouter cases for a
route like "/path/(\\d+)/(\\w+)" asserting params { _0, _1 } capture both
groups, and add cases for modifier-preserving suffixes such as "/a/:id+/tail"
and "/a/:id*/tail" verifying the route selection and that the "tail" suffix
remains matched; update the existing unnamed regex group tests (the testRouter
invocations) to include these new patterns and corresponding expected
data/params so unnamed-group indexing and suffix-preserving modifier behavior
are asserted.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/operations/add.ts`:
- Around line 65-66: getParamRegexp currently resets unnamed capture naming per
segment, causing duplicate capture names like "_0" when multiple segments are
combined; update the call site where paramsRegexp[i] = regexp (and the similar
block at lines 131-138) to ensure unnamed captures are globally unique by
passing a segment-unique prefix or global counter into getParamRegexp (e.g.,
include the segment index or a route-level counter) and have getParamRegexp
incorporate that prefix into generated unnamed names; modify getParamRegexp
signature to accept that prefix and adjust callers (the place assigning
paramsRegexp[i] and the other block) so that assembled param names cannot
collide across segments.
- Around line 124-126: The expansion currently throws away suf and any inline
constraint because it only uses the param name (const name =
m[1].match(/:(\w+)/)?.[1]) and builds wc from pre.concat(`**:${name}`); fix by
extracting the full parameter token (including its constraint/regex) from m[1]
rather than just the name, and include suf when composing wc and the fallback
path; i.e., use the original matched segment (the full ":param(...)" or
":param+" token) in the wc construction and append the suf segments (if any) to
the returned paths so patterns like `/a/:id+/tail` and `:id(\d+)+/*` preserve
trailing segments and inline constraints.

In `@src/regexp.ts`:
- Around line 12-13: The current branch that routes segments into the
regex-parameter path triggers on any "(" or ":" even when the "(" is escaped
(e.g., "\("), causing literal parentheses to be treated as captures; update the
condition and pattern matching to only consider unescaped "(" characters —
replace segment.includes("(") with a check for an unescaped parenthesis (use a
negative-lookbehind or equivalent check for a preceding backslash) and update
the modMatch logic (the pattern matching anchored in the code that currently
uses /^(.*:\w+(?:\([^)]*\))?)([?+*])$/) to require unescaped "(" when detecting
parameter captures; apply the same change at the other occurrence referenced
(around line 37) so escaped "\(" remain literal and are not rewritten as named
captures.
- Around line 27-29: The current branch in src/regexp.ts that pushes into
reSegments replaces any inline constraint with .+ / .*, which ignores user regex
like :id(\d+)+; instead, preserve the original inner pattern when mod is '+' or
'*' by applying the quantifier to the inner pattern rather than swapping it for
`.+`/`.*`: construct the named capture using the original constraint (e.g., the
extracted pattern variable) and apply the modifier outside it (for example wrap
as a non-capturing group if needed) so you emit a capture like
`(?<name>(?:<originalPattern>)+)` or `(?<name>(?:<originalPattern>)* )` rather
than `(?<name>.+)`/`(?<name>.*)`; update the push to reSegments to reference the
pattern variable instead of hardcoded .+/.* while keeping existing name and mod
variables.

---

Nitpick comments:
In `@test/router.test.ts`:
- Around line 480-536: Add regression tests to cover multi-unnamed regex groups
and mid-path modifiers: add testRouter cases for a route like
"/path/(\\d+)/(\\w+)" asserting params { _0, _1 } capture both groups, and add
cases for modifier-preserving suffixes such as "/a/:id+/tail" and "/a/:id*/tail"
verifying the route selection and that the "tail" suffix remains matched; update
the existing unnamed regex group tests (the testRouter invocations) to include
these new patterns and corresponding expected data/params so unnamed-group
indexing and suffix-preserving modifier behavior are asserted.

ℹ️ Review info

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 2ec1bd8 and c14d31a.

📒 Files selected for processing (5)
  • src/operations/add.ts
  • src/regexp.ts
  • test/bench/bundle.test.ts
  • test/regexp.test.ts
  • test/router.test.ts

Comment thread src/operations/add.ts Outdated
Comment thread src/operations/add.ts Outdated
Comment thread src/regexp.ts Outdated
Comment thread src/regexp.ts Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Duplicate comments (4)
src/operations/add.ts (2)

127-129: ⚠️ Potential issue | 🔴 Critical

+/* expansion drops trailing segments and widens constrained params.

Lines [128]-[129] only compose from pre, so /a/:id+/tail loses /tail. This branch also strips :id(\d+)+ down to **:id, removing its constraint.

💡 Proposed fix
-    const name = m[1].match(/:(\w+)/)?.[1] || "_";
-    const wc = "/" + pre.concat(`**:${name}`).join("/");
-    return m[2] === "+" ? [wc] : [wc, "/" + pre.join("/")];
+    const [, base, mod] = m;
+    const name = base.match(/:(\w+)/)?.[1] || "_";
+    const hasInlinePattern = /\([^)]*\)/.test(base);
+    if (hasInlinePattern && (mod === "+" || mod === "*")) {
+      throw new Error(`Unsupported modifier with inline pattern: "${segments[i]}"`);
+    }
+    const withWildcard = "/" + [...pre, `**:${name}`, ...suf].join("/");
+    const withoutWildcard = "/" + [...pre, ...suf].join("/");
+    return mod === "+" ? [withWildcard] : [withWildcard, withoutWildcard];
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/operations/add.ts` around lines 127 - 129, The '+' expansion branch
currently builds wc from only pre and a simplified `**:${name}`, which drops any
trailing segments and strips parameter constraints; update the m[2] === "+"
branch so wc is composed from pre + the wildcard-replaced current segment + any
original following segments (preserve the tail after the matched param) and
preserve the original param constraint/text (use the full matched token from
m[1] rather than only the captured name) when forming the wildcard segment;
adjust the code that sets `name`, `wc`, and the return for the `m[2] === "+"`
case accordingly so `/a/:id+(\\d+)/tail` becomes `/a/**:id(\\d+)/tail` (and
similar) instead of losing `/tail` or the constraint.

68-71: ⚠️ Potential issue | 🟠 Major

Unnamed regex capture names can collide across segments.

Line [134] resets unnamed capture indexing per segment. Routes with multiple unnamed regex segments can reuse _0 and overwrite params.

💡 Proposed fix
-      } else if (segment.includes(":", 1) || segment.includes("(")) {
-        const regexp = getParamRegexp(segment);
+      } else if (segment.includes(":", 1) || segment.includes("(")) {
+        const [regexp, nextUnnamedIndex] = getParamRegexp(
+          segment,
+          _unnamedParamIndex,
+        );
+        _unnamedParamIndex = nextUnnamedIndex;
         paramsRegexp[i] = regexp;
         node.hasRegexParam = true;
         paramsMap.push([i, regexp, false]);
       } else {
         paramsMap.push([i, segment.slice(1), false]);
       }
@@
-function getParamRegexp(segment: string): RegExp {
-  let _i = 0;
+function getParamRegexp(
+  segment: string,
+  unnamedStart = 0,
+): [RegExp, number] {
+  let _i = unnamedStart;
@@
-  return new RegExp(`^${regex}$`);
+  return [new RegExp(`^${regex}$`), _i];
}

Also applies to: 133-143

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/operations/add.ts` around lines 68 - 71, The unnamed regex capture names
are being reset per segment causing collisions when a route has multiple unnamed
regex segments; update the logic around getParamRegexp/paramsRegexp/paramsMap so
unnamed capture names are globally unique across the entire route: introduce and
pass a shared unnamedIndex (or use the segment index as part of the name) into
getParamRegexp instead of resetting it per segment, increment the shared counter
as each unnamed capture is created, and store that unique capture name in
paramsMap (the entry pushed to paramsMap for each param should reference the
generated unique name); ensure node.hasRegexParam remains set but do not reset
the unnamed counter per segment.
src/regexp.ts (2)

27-29: ⚠️ Potential issue | 🟠 Major

+ / * modifiers discard inline regex constraints.

Line [28] emits .+ / .*, so constrained routes like :id(\d+)+ and :id(\d+)* accept non-matching values.

💡 Proposed fix
-        // + or *
-        reSegments.push(mod === "+" ? `?(?<${name}>.+)` : `?(?<${name}>.*)`);
+        // + or * (preserve inline constraint when present)
+        const pattern = base.match(/:(\w+)(?:\(([^)]*)\))?/)?.[2] || "[^/]+";
+        const repeated = `${pattern}(?:/${pattern})*`;
+        reSegments.push(
+          mod === "+"
+            ? `?(?<${name}>${repeated})`
+            : `?(?<${name}>${repeated})?`,
+        );
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/regexp.ts` around lines 27 - 29, The current branch that handles +/*
modifiers always emits .+/.*, discarding any inline constraint; update the
reSegments push to reuse the parsed constraint (e.g., a variable like
pattern/constraint) when present by wrapping it in a non-capturing group and
applying the quantifier: for "+" emit ?(?<name>(?:<constraint>)+) and for "*"
emit ?(?<name>(?:<constraint>)*); if no constraint exists, fall back to the
existing .+/.* behavior. Locate the code building reSegments (the block that
checks mod === "+" / mod === "*" and references name) and replace the string
templates so they inject the constraint variable instead of hard-coded .+/.*
while preserving the existing optional prefix and named capture.

12-13: ⚠️ Potential issue | 🟠 Major

Escaped literal parentheses are still parsed as capture groups.

Line [12] routes any ( to regex-parameter logic, and Line [37] rewrites escaped \( too. That breaks literal-parenthesis routes.

💡 Proposed fix
-    } else if (segment.includes(":") || segment.includes("(")) {
+    } else if (segment.includes(":") || /(^|[^\\])\(/.test(segment)) {
@@
-          .replace(/\((?![?<])/g, () => `(?<_${idCtr++}>`)
+          .replace(/(^|[^\\])\((?![?<])/g, (_, p) => `${p}(?<_${idCtr++}>`)

Also applies to: 37-37

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/regexp.ts` around lines 12 - 13, The branch `if (segment.includes(":") ||
segment.includes("("))` incorrectly treats escaped literals like "\(" or "\:" as
regex parameters; change the guard to detect unescaped characters only (e.g.
test for an unescaped "(" and ":" using something like /(^|[^\\])\(/ and
/(^|[^\\]):/), and ensure the subsequent `modMatch` logic (the `const modMatch =
segment.match(/^(.*:\w+(?:\([^)]*\))?)([?+*])$/);` handling) only runs for
segments with unescaped colons/parentheses so escaped `\(` and `\:` remain
literal and are not parsed as capture groups.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@src/operations/add.ts`:
- Around line 127-129: The '+' expansion branch currently builds wc from only
pre and a simplified `**:${name}`, which drops any trailing segments and strips
parameter constraints; update the m[2] === "+" branch so wc is composed from pre
+ the wildcard-replaced current segment + any original following segments
(preserve the tail after the matched param) and preserve the original param
constraint/text (use the full matched token from m[1] rather than only the
captured name) when forming the wildcard segment; adjust the code that sets
`name`, `wc`, and the return for the `m[2] === "+"` case accordingly so
`/a/:id+(\\d+)/tail` becomes `/a/**:id(\\d+)/tail` (and similar) instead of
losing `/tail` or the constraint.
- Around line 68-71: The unnamed regex capture names are being reset per segment
causing collisions when a route has multiple unnamed regex segments; update the
logic around getParamRegexp/paramsRegexp/paramsMap so unnamed capture names are
globally unique across the entire route: introduce and pass a shared
unnamedIndex (or use the segment index as part of the name) into getParamRegexp
instead of resetting it per segment, increment the shared counter as each
unnamed capture is created, and store that unique capture name in paramsMap (the
entry pushed to paramsMap for each param should reference the generated unique
name); ensure node.hasRegexParam remains set but do not reset the unnamed
counter per segment.

In `@src/regexp.ts`:
- Around line 27-29: The current branch that handles +/* modifiers always emits
.+/.*, discarding any inline constraint; update the reSegments push to reuse the
parsed constraint (e.g., a variable like pattern/constraint) when present by
wrapping it in a non-capturing group and applying the quantifier: for "+" emit
?(?<name>(?:<constraint>)+) and for "*" emit ?(?<name>(?:<constraint>)*); if no
constraint exists, fall back to the existing .+/.* behavior. Locate the code
building reSegments (the block that checks mod === "+" / mod === "*" and
references name) and replace the string templates so they inject the constraint
variable instead of hard-coded .+/.* while preserving the existing optional
prefix and named capture.
- Around line 12-13: The branch `if (segment.includes(":") ||
segment.includes("("))` incorrectly treats escaped literals like "\(" or "\:" as
regex parameters; change the guard to detect unescaped characters only (e.g.
test for an unescaped "(" and ":" using something like /(^|[^\\])\(/ and
/(^|[^\\]):/), and ensure the subsequent `modMatch` logic (the `const modMatch =
segment.match(/^(.*:\w+(?:\([^)]*\))?)([?+*])$/);` handling) only runs for
segments with unescaped colons/parentheses so escaped `\(` and `\:` remain
literal and are not parsed as capture groups.

ℹ️ Review info

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between c14d31a and cb06fbc.

📒 Files selected for processing (4)
  • src/operations/add.ts
  • src/regexp.ts
  • test/regexp.test.ts
  • test/router.test.ts

Document all supported route patterns including static, named params,
wildcards, regex constraints, unnamed groups, and modifiers.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@README.md`:
- Line 103: Replace each unlabeled fenced code block in README.md (the example
blocks showing routes and arrow outputs) with a labeled block by changing the
opening ``` to ```text so Markdown lint MD040 is satisfied; specifically update
the unlabeled fences wrapping the route examples (the blocks containing lines
like "/path/to/resource", "/users/:name        →  /users/foo        → { name:
\"foo\" }", "/path/**            →  /path/foo/bar     → {}", the regex and
optional/greedy param examples, and all similar example fences) to use ```text.

ℹ️ Review info

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between cb06fbc and 16cfb44.

📒 Files selected for processing (1)
  • README.md

Comment thread README.md Outdated
@codecov

codecov Bot commented Feb 27, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 94.64286% with 15 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
src/regexp.ts 88.67% 6 Missing ⚠️
src/_segment-wildcards.ts 91.83% 4 Missing ⚠️
src/operations/remove.ts 88.46% 3 Missing ⚠️
src/_group-delimiters.ts 97.87% 1 Missing ⚠️
src/operations/find-all.ts 50.00% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Duplicate comments (1)
src/operations/add.ts (1)

46-57: ⚠️ Potential issue | 🔴 Critical

+/* expansion is still incorrect for constrained or non-terminal routes.

Line 129 rewrites +/* into **:${name}, which strips inline constraints (e.g. :id(\d+)+). Also, wildcard matching is terminal (Line 56 break), so suffixes added in Line 129 are never enforced (/a/:id+/tail effectively degrades to /a/**:id).

💡 Safe short-term guard (prevents silent misrouting until full support exists)
 function _expandModifiers(segments: string[]): string[] | undefined {
   for (let i = 0; i < segments.length; i++) {
     const m = segments[i].match(/^(.*:\w+(?:\([^)]*\))?)([?+*])$/);
     if (!m) continue;
     const pre = segments.slice(0, i);
     const suf = segments.slice(i + 1);
     if (m[2] === "?") {
       return [
         "/" + pre.concat(m[1]).concat(suf).join("/"),
         "/" + pre.concat(suf).join("/"),
       ];
     }
+    const hasInlineConstraint = /\([^)]*\)/.test(m[1]);
+    const hasTrailingSegments = suf.length > 0;
+    if (hasInlineConstraint || hasTrailingSegments) {
+      throw new Error(`Unsupported modifier pattern: "${segments[i]}"`);
+    }
     const name = m[1].match(/:(\w+)/)?.[1] || "_";
-    const wc = "/" + [...pre, `**:${name}`, ...suf].join("/");
-    const without = "/" + [...pre, ...suf].join("/");
+    const wc = "/" + [...pre, `**:${name}`].join("/");
+    const without = "/" + pre.join("/");
     return m[2] === "+" ? [wc] : [wc, without];
   }
 }

Also applies to: 128-131

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/operations/add.ts` around lines 46 - 57, The current handling of "**"
rewrites "+/*" into a terminal wildcard that strips inline constraints and stops
processing suffixes; change the logic in src/operations/add.ts where
segment.startsWith("**") is handled so that you (1) preserve the entire inline
parameter token (everything after ":" including constraint regex and quantifier)
when pushing into paramsMap instead of truncating to just the name, and (2) do
not unconditionally break out of the segment-processing loop so that
non-terminal wildcard expansions can still allow subsequent segments to be
enforced. Update the block that sets node.wildcard and the paramsMap push
(referencing node.wildcard, paramsMap, and the segment.startsWith("**") branch)
to keep the original parameter string and to continue processing remaining
segments when the wildcard is non-terminal.
🧹 Nitpick comments (1)
test/router.test.ts (1)

520-582: Add tests for constrained/non-terminal +/* to lock behavior.

Current modifier tests cover terminal unconstrained :name+ and :name*, but not cases like /:id(\d+)+ or /a/:id+/tail. Those are high-risk paths for expansion logic regressions.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/router.test.ts` around lines 520 - 582, Add tests to cover constrained
and non-terminal +/* modifiers to prevent regressions: inside the "url pattern
modifiers" describe block add testRouter cases using patterns like
"/:id(\\d+)+", "/:id(\\d+)*", "/a/:id+/tail" and "/a/:id*/tail" (i.e., pass
those pattern arrays into testRouter) and assert expected matches and
non-matches — for constrained "+/*" ensure numeric regex-only matches (e.g.,
"/123/456" matches "/:id(\\d+)+", "/abc" does not), and for non-terminal
variants ensure trailing segments are required/optional appropriately (e.g.,
"/a/1/2/tail" matches "/a/:id+/tail", "/a/tail" does not for + but does for *);
put these alongside the existing tests so testRouter and the existing pattern
strings (e.g., "/files/:path+", "/files/:path*") are referenced for consistency.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@src/operations/add.ts`:
- Around line 46-57: The current handling of "**" rewrites "+/*" into a terminal
wildcard that strips inline constraints and stops processing suffixes; change
the logic in src/operations/add.ts where segment.startsWith("**") is handled so
that you (1) preserve the entire inline parameter token (everything after ":"
including constraint regex and quantifier) when pushing into paramsMap instead
of truncating to just the name, and (2) do not unconditionally break out of the
segment-processing loop so that non-terminal wildcard expansions can still allow
subsequent segments to be enforced. Update the block that sets node.wildcard and
the paramsMap push (referencing node.wildcard, paramsMap, and the
segment.startsWith("**") branch) to keep the original parameter string and to
continue processing remaining segments when the wildcard is non-terminal.

---

Nitpick comments:
In `@test/router.test.ts`:
- Around line 520-582: Add tests to cover constrained and non-terminal +/*
modifiers to prevent regressions: inside the "url pattern modifiers" describe
block add testRouter cases using patterns like "/:id(\\d+)+", "/:id(\\d+)*",
"/a/:id+/tail" and "/a/:id*/tail" (i.e., pass those pattern arrays into
testRouter) and assert expected matches and non-matches — for constrained "+/*"
ensure numeric regex-only matches (e.g., "/123/456" matches "/:id(\\d+)+",
"/abc" does not), and for non-terminal variants ensure trailing segments are
required/optional appropriately (e.g., "/a/1/2/tail" matches "/a/:id+/tail",
"/a/tail" does not for + but does for *); put these alongside the existing tests
so testRouter and the existing pattern strings (e.g., "/files/:path+",
"/files/:path*") are referenced for consistency.

ℹ️ Review info

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 16cfb44 and bf78005.

📒 Files selected for processing (5)
  • README.md
  • src/operations/add.ts
  • src/regexp.ts
  • test/regexp.test.ts
  • test/router.test.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • README.md

expand {…} routes across add/remove/regexp with tests and docs

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

♻️ Duplicate comments (1)
src/operations/add.ts (1)

139-143: ⚠️ Potential issue | 🟠 Major

Constrained +/* routes lose their inline regex during expansion.

_expandModifiers reduces :id(\d+)+ / :id(\d+)* to **:id, so constraints are dropped and non-matching paths are accepted.

💡 Proposed fix (safe behavior until constrained wildcard validation exists)
-    const name = m[1].match(/:(\w+)/)?.[1] || "_";
+    const base = m[1];
+    const name = base.match(/:(\w+)/)?.[1] || "_";
+    const hasInlinePattern = /:\w+\([^)]*\)/.test(base);
+    if (hasInlinePattern) {
+      throw new Error(
+        `unsupported constrained repeat modifier in radix matcher: "${segments[i]}"`,
+      );
+    }
     const wc = "/" + [...pre, `**:${name}`, ...suf].join("/");
     const without = "/" + [...pre, ...suf].join("/");
     return m[2] === "+" ? [wc] : [wc, without];
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/operations/add.ts` around lines 139 - 143, _expandModifiers currently
extracts only the parameter name with m[1].match(/:(\w+)/) which drops any
inline regex, so when building wc it emits `**:name` and loses constraints;
update the extraction to capture the optional inline constraint (e.g. use
something like m[1].match(/:(\w+)(\([^)]+\))?/) to get both name and regex) and
build the wildcard segment as `**:${name}${regex || ''}` (i.e. preserve the
captured `(regex)` when constructing wc in the _expandModifiers logic) so
constrained modifiers keep their inline regex.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/operations/remove.ts`:
- Around line 13-23: removeRoute currently only expands group delimiters (via
expandGroupDelimiters(path)) and can miss routes that were stored after modifier
expansion (e.g., :id?, :path+, :path*); update removeRoute to mirror addRoute by
also expanding modifier delimiters before proceeding: call the same
modifier-expansion helper used by addRoute (the function that returns expanded
modifier paths), iterate over each expanded modifier path and call
removeRoute(ctx, method, expandedModifierPath) just like you do for group
expansions, then fall back to splitPath and _remove(ctx.root, method || "",
segments, 0) when no expansions apply.

---

Duplicate comments:
In `@src/operations/add.ts`:
- Around line 139-143: _expandModifiers currently extracts only the parameter
name with m[1].match(/:(\w+)/) which drops any inline regex, so when building wc
it emits `**:name` and loses constraints; update the extraction to capture the
optional inline constraint (e.g. use something like
m[1].match(/:(\w+)(\([^)]+\))?/) to get both name and regex) and build the
wildcard segment as `**:${name}${regex || ''}` (i.e. preserve the captured
`(regex)` when constructing wc in the _expandModifiers logic) so constrained
modifiers keep their inline regex.

ℹ️ Review info

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between bf78005 and 411d907.

📒 Files selected for processing (9)
  • AGENTS.md
  • README.md
  • src/_group-delimiters.ts
  • src/operations/add.ts
  • src/operations/remove.ts
  • src/regexp.ts
  • test/bench/bundle.test.ts
  • test/regexp.test.ts
  • test/router.test.ts
✅ Files skipped from review due to trivial changes (1)
  • README.md

Comment thread src/operations/remove.ts
Comment thread src/regexp.ts
@pi0 pi0 changed the title feat: url pattern compatibility feat!: url pattern compatibility Feb 27, 2026
pi0 and others added 12 commits February 28, 2026 00:05
handle mid-segment * patterns in matching, removal, regexp, docs, and tests
align matcher output with urlpattern-style unnamed group keys
`removeRoute` now expands `:name?`, `:name+`, `:name*` modifiers (and
escape sequences) the same way `addRoute` does, so removing a route by
its original modifier path works correctly. Adds regression tests.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@pi0
pi0 merged commit b26b3db into main Mar 9, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant