route-parser-ts 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -11,8 +11,10 @@ export interface RouteParams {
11
11
  declare class RouteParser {
12
12
  spec: string;
13
13
  private tokens;
14
- private regex;
15
- private paramNames;
14
+ private pathRegex;
15
+ private pathParamNames;
16
+ private queryParamDefs;
17
+ private hasQueryInSpec;
16
18
  constructor(spec: string);
17
19
  /**
18
20
  * Match a path against this route
package/dist/index.js CHANGED
@@ -53,10 +53,15 @@ function tokenize(spec) {
53
53
  const children = tokenize(optionalContent);
54
54
  tokens.push({ type: 'optional', value: optionalContent, children });
55
55
  }
56
+ else if (char === '?' || char === '&') {
57
+ // Query separator - mark it specially
58
+ tokens.push({ type: 'querySeparator', value: char });
59
+ i++;
60
+ }
56
61
  else {
57
62
  // Static text - consume until we hit a special character
58
63
  let text = '';
59
- while (i < spec.length && !':*()'.includes(spec[i])) {
64
+ while (i < spec.length && !':*()&?'.includes(spec[i])) {
60
65
  text += spec[i];
61
66
  i++;
62
67
  }
@@ -67,6 +72,79 @@ function tokenize(spec) {
67
72
  }
68
73
  return tokens;
69
74
  }
75
+ /**
76
+ * Check if tokens contain any query-related content (? or &)
77
+ */
78
+ function hasQueryTokens(tokens) {
79
+ for (const token of tokens) {
80
+ if (token.type === 'querySeparator')
81
+ return true;
82
+ if (token.type === 'optional' && token.children && hasQueryTokens(token.children))
83
+ return true;
84
+ }
85
+ return false;
86
+ }
87
+ /**
88
+ * Extract query parameter definitions from tokens
89
+ */
90
+ function extractQueryParams(tokens, optional = false) {
91
+ const params = [];
92
+ for (let i = 0; i < tokens.length; i++) {
93
+ const token = tokens[i];
94
+ if (token.type === 'param') {
95
+ // Look back for the key name (e.g., 'page=' before ':page')
96
+ let key = token.value; // Default to param name
97
+ if (i > 0) {
98
+ const prevToken = tokens[i - 1];
99
+ if (prevToken.type === 'static' && prevToken.value.endsWith('=')) {
100
+ // Extract key from "key="
101
+ const match = prevToken.value.match(/([^=&?]+)=$/);
102
+ if (match) {
103
+ key = match[1];
104
+ }
105
+ }
106
+ }
107
+ params.push({ name: token.value, key, optional });
108
+ }
109
+ else if (token.type === 'optional' && token.children) {
110
+ // Recursively extract from optional segments
111
+ params.push(...extractQueryParams(token.children, true));
112
+ }
113
+ }
114
+ return params;
115
+ }
116
+ /**
117
+ * Split tokens into path tokens and query tokens
118
+ */
119
+ function splitPathAndQuery(tokens) {
120
+ const pathTokens = [];
121
+ const queryTokens = [];
122
+ let inQuery = false;
123
+ for (const token of tokens) {
124
+ if (token.type === 'querySeparator' && token.value === '?') {
125
+ inQuery = true;
126
+ queryTokens.push(token);
127
+ }
128
+ else if (inQuery) {
129
+ queryTokens.push(token);
130
+ }
131
+ else if (token.type === 'optional' && token.children) {
132
+ // Check if this optional contains query content
133
+ if (hasQueryTokens(token.children)) {
134
+ // This optional is part of query
135
+ queryTokens.push(token);
136
+ inQuery = true; // After query optional, we're in query mode
137
+ }
138
+ else {
139
+ pathTokens.push(token);
140
+ }
141
+ }
142
+ else {
143
+ pathTokens.push(token);
144
+ }
145
+ }
146
+ return { pathTokens, queryTokens };
147
+ }
70
148
  /**
71
149
  * Escape special regex characters in a string
72
150
  */
@@ -74,9 +152,9 @@ function escapeRegex(str) {
74
152
  return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
75
153
  }
76
154
  /**
77
- * Build a regex pattern from tokens
155
+ * Build a regex pattern from path tokens only (no query handling)
78
156
  */
79
- function buildRegex(tokens) {
157
+ function buildPathRegex(tokens) {
80
158
  let pattern = '';
81
159
  const paramNames = [];
82
160
  for (const token of tokens) {
@@ -97,19 +175,41 @@ function buildRegex(tokens) {
97
175
  case 'optional':
98
176
  // Optional segment - recursively build and wrap in optional group
99
177
  if (token.children) {
100
- const { pattern: childPattern, paramNames: childNames } = buildRegex(token.children);
178
+ const { pattern: childPattern, paramNames: childNames } = buildPathRegex(token.children);
101
179
  pattern += `(?:${childPattern})?`;
102
180
  paramNames.push(...childNames);
103
181
  }
104
182
  break;
183
+ case 'querySeparator':
184
+ // Should not appear in path tokens
185
+ break;
105
186
  }
106
187
  }
107
188
  return { pattern, paramNames };
108
189
  }
190
+ /**
191
+ * Parse a query string into key-value pairs
192
+ */
193
+ function parseQueryString(queryString) {
194
+ const params = new Map();
195
+ if (!queryString)
196
+ return params;
197
+ // Remove leading ? if present
198
+ const qs = queryString.startsWith('?') ? queryString.slice(1) : queryString;
199
+ if (!qs)
200
+ return params;
201
+ for (const pair of qs.split('&')) {
202
+ const [key, value] = pair.split('=');
203
+ if (key) {
204
+ params.set(key, value || '');
205
+ }
206
+ }
207
+ return params;
208
+ }
109
209
  /**
110
210
  * Attempt to reverse a route with given parameters
111
211
  */
112
- function reverseTokens(tokens, params) {
212
+ function reverseTokens(tokens, params, isFirstQueryParam) {
113
213
  let result = '';
114
214
  for (const token of tokens) {
115
215
  switch (token.type) {
@@ -125,10 +225,31 @@ function reverseTokens(tokens, params) {
125
225
  result += String(value);
126
226
  break;
127
227
  }
228
+ case 'querySeparator':
229
+ // For reverse, we handle ? and & smartly
230
+ if (token.value === '?') {
231
+ if (isFirstQueryParam.value) {
232
+ result += '?';
233
+ isFirstQueryParam.value = false;
234
+ }
235
+ else {
236
+ result += '&';
237
+ }
238
+ }
239
+ else if (token.value === '&') {
240
+ if (isFirstQueryParam.value) {
241
+ result += '?';
242
+ isFirstQueryParam.value = false;
243
+ }
244
+ else {
245
+ result += '&';
246
+ }
247
+ }
248
+ break;
128
249
  case 'optional':
129
250
  if (token.children) {
130
251
  // Try to fill the optional segment
131
- const optionalResult = reverseTokens(token.children, params);
252
+ const optionalResult = reverseTokens(token.children, params, isFirstQueryParam);
132
253
  if (optionalResult !== false) {
133
254
  result += optionalResult;
134
255
  }
@@ -163,31 +284,61 @@ function canFulfill(tokens, params) {
163
284
  class RouteParser {
164
285
  spec;
165
286
  tokens;
166
- regex;
167
- paramNames;
287
+ pathRegex;
288
+ pathParamNames;
289
+ queryParamDefs;
290
+ hasQueryInSpec;
168
291
  constructor(spec) {
169
292
  if (!spec) {
170
293
  throw new Error('spec is required');
171
294
  }
172
295
  this.spec = spec;
173
296
  this.tokens = tokenize(spec);
174
- const { pattern, paramNames } = buildRegex(this.tokens);
175
- // Match from start to end, with optional query string
176
- this.regex = new RegExp(`^${pattern}(?:\\?.*)?$`);
177
- this.paramNames = paramNames;
297
+ // Split into path and query parts
298
+ const { pathTokens, queryTokens } = splitPathAndQuery(this.tokens);
299
+ // Build regex for path part only
300
+ const { pattern, paramNames } = buildPathRegex(pathTokens);
301
+ this.pathRegex = new RegExp(`^${pattern}(?:\\?.*)?$`);
302
+ this.pathParamNames = paramNames;
303
+ // Extract query parameter definitions
304
+ this.queryParamDefs = extractQueryParams(queryTokens);
305
+ this.hasQueryInSpec = queryTokens.length > 0;
178
306
  }
179
307
  /**
180
308
  * Match a path against this route
181
309
  * Returns params object on match, false otherwise
182
310
  */
183
311
  match(path) {
184
- const match = this.regex.exec(path);
312
+ // Split path into pathname and query string
313
+ const queryIndex = path.indexOf('?');
314
+ const pathname = queryIndex >= 0 ? path.slice(0, queryIndex) : path;
315
+ const queryString = queryIndex >= 0 ? path.slice(queryIndex + 1) : '';
316
+ // Match only the pathname part (not query string) to avoid capturing ? in params
317
+ const match = this.pathRegex.exec(pathname);
185
318
  if (!match) {
186
319
  return false;
187
320
  }
188
321
  const params = {};
189
- for (let i = 0; i < this.paramNames.length; i++) {
190
- params[this.paramNames[i]] = match[i + 1];
322
+ // Extract path parameters
323
+ for (let i = 0; i < this.pathParamNames.length; i++) {
324
+ params[this.pathParamNames[i]] = match[i + 1];
325
+ }
326
+ // If route has query params in spec, match them from URL's query string
327
+ if (this.hasQueryInSpec) {
328
+ const urlQueryParams = parseQueryString(queryString);
329
+ for (const paramDef of this.queryParamDefs) {
330
+ const value = urlQueryParams.get(paramDef.key);
331
+ if (value !== undefined) {
332
+ params[paramDef.name] = value;
333
+ }
334
+ else if (paramDef.optional) {
335
+ params[paramDef.name] = undefined;
336
+ }
337
+ else {
338
+ // Required query param not found
339
+ return false;
340
+ }
341
+ }
191
342
  }
192
343
  return params;
193
344
  }
@@ -200,7 +351,8 @@ class RouteParser {
200
351
  if (!this.canFulfillRequired(this.tokens, params)) {
201
352
  return false;
202
353
  }
203
- return reverseTokens(this.tokens, params);
354
+ const isFirstQueryParam = { value: true };
355
+ return reverseTokens(this.tokens, params, isFirstQueryParam);
204
356
  }
205
357
  /**
206
358
  * Check if required params (non-optional) can be fulfilled
package/dist/test.js CHANGED
@@ -107,6 +107,118 @@ describe('Route', () => {
107
107
  assert.strictEqual(route.reverse({ id: '456' }), '~/users/456');
108
108
  });
109
109
  });
110
+ describe('query parameters', () => {
111
+ it('should match a path with query string when route has no query params', () => {
112
+ const route = Route('/foo');
113
+ assert.deepStrictEqual(route.match('/foo?a=1&b=2'), {});
114
+ });
115
+ it('should match a path without query string when route has no query params', () => {
116
+ const route = Route('/foo');
117
+ assert.deepStrictEqual(route.match('/foo'), {});
118
+ });
119
+ it('should match query parameters in same order', () => {
120
+ const route = Route('/?a=:a&b=:b');
121
+ assert.deepStrictEqual(route.match('/?a=1&b=2'), { a: '1', b: '2' });
122
+ });
123
+ it('should match query parameters in different order (issue #17)', () => {
124
+ const route = Route('/?a=:a&b=:b');
125
+ // This test documents the expected behavior - query order should not matter
126
+ assert.deepStrictEqual(route.match('/?b=2&a=1'), { a: '1', b: '2' });
127
+ });
128
+ it('should match path with parameters and query string', () => {
129
+ const route = Route('/users/:id');
130
+ assert.deepStrictEqual(route.match('/users/123?tab=profile'), { id: '123' });
131
+ });
132
+ it('should match path with parameters and multiple query params', () => {
133
+ const route = Route('/users/:id');
134
+ assert.deepStrictEqual(route.match('/users/123?tab=profile&view=grid'), { id: '123' });
135
+ });
136
+ });
137
+ describe('optional query parameters', () => {
138
+ it('should match route with optional query part - without query', () => {
139
+ const route = Route('/search(?q=:query)');
140
+ assert.deepStrictEqual(route.match('/search'), { query: undefined });
141
+ });
142
+ it('should match route with optional query part - with query', () => {
143
+ const route = Route('/search(?q=:query)');
144
+ assert.deepStrictEqual(route.match('/search?q=test'), { query: 'test' });
145
+ });
146
+ it('should match route with optional query params - without any query', () => {
147
+ const route = Route('/api/items(?page=:page(&limit=:limit))');
148
+ assert.deepStrictEqual(route.match('/api/items'), { page: undefined, limit: undefined });
149
+ });
150
+ it('should match route with optional query params - with first param only', () => {
151
+ const route = Route('/api/items(?page=:page(&limit=:limit))');
152
+ assert.deepStrictEqual(route.match('/api/items?page=1'), { page: '1', limit: undefined });
153
+ });
154
+ it('should match route with optional query params - with all params', () => {
155
+ const route = Route('/api/items(?page=:page(&limit=:limit))');
156
+ assert.deepStrictEqual(route.match('/api/items?page=1&limit=10'), { page: '1', limit: '10' });
157
+ });
158
+ it('should reverse route with optional query part - without params', () => {
159
+ const route = Route('/search(?q=:query)');
160
+ assert.strictEqual(route.reverse({}), '/search');
161
+ });
162
+ it('should reverse route with optional query part - with params', () => {
163
+ const route = Route('/search(?q=:query)');
164
+ assert.strictEqual(route.reverse({ query: 'test' }), '/search?q=test');
165
+ });
166
+ it('should reverse route with nested optional query params - partial', () => {
167
+ const route = Route('/api/items(?page=:page(&limit=:limit))');
168
+ assert.strictEqual(route.reverse({ page: '1' }), '/api/items?page=1');
169
+ });
170
+ it('should reverse route with nested optional query params - all', () => {
171
+ const route = Route('/api/items(?page=:page(&limit=:limit))');
172
+ assert.strictEqual(route.reverse({ page: '1', limit: '10' }), '/api/items?page=1&limit=10');
173
+ });
174
+ describe('independently optional query parameters', () => {
175
+ // Pattern: /search(?param1=:param1)(&param2=:param2)
176
+ // The ? and & should be intelligently handled during matching
177
+ it('should match with no query params', () => {
178
+ const route = Route('/search(?param1=:param1)(&param2=:param2)');
179
+ assert.deepStrictEqual(route.match('/search'), { param1: undefined, param2: undefined });
180
+ });
181
+ it('should match with first param only', () => {
182
+ const route = Route('/search(?param1=:param1)(&param2=:param2)');
183
+ assert.deepStrictEqual(route.match('/search?param1=foo'), { param1: 'foo', param2: undefined });
184
+ });
185
+ it('should match with second param only', () => {
186
+ const route = Route('/search(?param1=:param1)(&param2=:param2)');
187
+ // When only second param present, ? should be used instead of &
188
+ assert.deepStrictEqual(route.match('/search?param2=bar'), { param1: undefined, param2: 'bar' });
189
+ });
190
+ it('should match with both params', () => {
191
+ const route = Route('/search(?param1=:param1)(&param2=:param2)');
192
+ assert.deepStrictEqual(route.match('/search?param1=foo&param2=bar'), { param1: 'foo', param2: 'bar' });
193
+ });
194
+ it('should match with both params in different order', () => {
195
+ const route = Route('/search(?param1=:param1)(&param2=:param2)');
196
+ assert.deepStrictEqual(route.match('/search?param2=bar&param1=foo'), { param1: 'foo', param2: 'bar' });
197
+ });
198
+ it('should reverse with no params', () => {
199
+ const route = Route('/search(?param1=:param1)(&param2=:param2)');
200
+ assert.strictEqual(route.reverse({}), '/search');
201
+ });
202
+ it('should reverse with first param only', () => {
203
+ const route = Route('/search(?param1=:param1)(&param2=:param2)');
204
+ assert.strictEqual(route.reverse({ param1: 'foo' }), '/search?param1=foo');
205
+ });
206
+ it('should reverse with both params', () => {
207
+ const route = Route('/search(?param1=:param1)(&param2=:param2)');
208
+ assert.strictEqual(route.reverse({ param1: 'foo', param2: 'bar' }), '/search?param1=foo&param2=bar');
209
+ });
210
+ // Route defined with params in reverse order - ? and & should not affect matching
211
+ it('should match when route defines params in reverse order', () => {
212
+ const route = Route('/search(?param2=:param2)(&param1=:param1)');
213
+ // URL has param1 first, route has param2 first - should still match
214
+ assert.deepStrictEqual(route.match('/search?param1=foo&param2=bar'), { param1: 'foo', param2: 'bar' });
215
+ });
216
+ it('should match when route and URL have same reverse order', () => {
217
+ const route = Route('/search(?param2=:param2)(&param1=:param1)');
218
+ assert.deepStrictEqual(route.match('/search?param2=bar&param1=foo'), { param1: 'foo', param2: 'bar' });
219
+ });
220
+ });
221
+ });
110
222
  describe('reverse', () => {
111
223
  it('reverses routes without params', () => {
112
224
  const route = Route('/foo');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "route-parser-ts",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "A TypeScript URL routing library with support for named parameters, splats, and optional segments",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",