@tanstack/eslint-plugin-router 1.141.0 → 1.155.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.
Files changed (34) hide show
  1. package/dist/cjs/index.cjs +4 -2
  2. package/dist/cjs/index.cjs.map +1 -1
  3. package/dist/cjs/rules/route-param-names/constants.cjs +13 -0
  4. package/dist/cjs/rules/route-param-names/constants.cjs.map +1 -0
  5. package/dist/cjs/rules/route-param-names/constants.d.cts +23 -0
  6. package/dist/cjs/rules/route-param-names/route-param-names.rule.cjs +98 -0
  7. package/dist/cjs/rules/route-param-names/route-param-names.rule.cjs.map +1 -0
  8. package/dist/cjs/rules/route-param-names/route-param-names.rule.d.cts +4 -0
  9. package/dist/cjs/rules/route-param-names/route-param-names.utils.cjs +61 -0
  10. package/dist/cjs/rules/route-param-names/route-param-names.utils.cjs.map +1 -0
  11. package/dist/cjs/rules/route-param-names/route-param-names.utils.d.cts +43 -0
  12. package/dist/cjs/rules.cjs +3 -1
  13. package/dist/cjs/rules.cjs.map +1 -1
  14. package/dist/esm/index.js +4 -2
  15. package/dist/esm/index.js.map +1 -1
  16. package/dist/esm/rules/route-param-names/constants.d.ts +23 -0
  17. package/dist/esm/rules/route-param-names/constants.js +13 -0
  18. package/dist/esm/rules/route-param-names/constants.js.map +1 -0
  19. package/dist/esm/rules/route-param-names/route-param-names.rule.d.ts +4 -0
  20. package/dist/esm/rules/route-param-names/route-param-names.rule.js +98 -0
  21. package/dist/esm/rules/route-param-names/route-param-names.rule.js.map +1 -0
  22. package/dist/esm/rules/route-param-names/route-param-names.utils.d.ts +43 -0
  23. package/dist/esm/rules/route-param-names/route-param-names.utils.js +61 -0
  24. package/dist/esm/rules/route-param-names/route-param-names.utils.js.map +1 -0
  25. package/dist/esm/rules.js +3 -1
  26. package/dist/esm/rules.js.map +1 -1
  27. package/package.json +1 -1
  28. package/src/__tests__/route-param-names.rule.test.ts +271 -0
  29. package/src/__tests__/route-param-names.utils.test.ts +174 -0
  30. package/src/index.ts +2 -0
  31. package/src/rules/route-param-names/constants.ts +36 -0
  32. package/src/rules/route-param-names/route-param-names.rule.ts +127 -0
  33. package/src/rules/route-param-names/route-param-names.utils.ts +122 -0
  34. package/src/rules.ts +2 -0
@@ -0,0 +1 @@
1
+ {"version":3,"file":"route-param-names.utils.js","sources":["../../../../src/rules/route-param-names/route-param-names.utils.ts"],"sourcesContent":["import { VALID_PARAM_NAME_REGEX } from './constants'\n\nexport interface ExtractedParam {\n /** The full param string including $ prefix (e.g., \"$userId\", \"-$optional\") */\n fullParam: string\n /** The param name without $ prefix (e.g., \"userId\", \"optional\") */\n paramName: string\n /** Whether this is an optional param (prefixed with -$) */\n isOptional: boolean\n /** Whether this param name is valid */\n isValid: boolean\n}\n\n/**\n * Extracts param names from a route path segment.\n *\n * Handles these patterns:\n * - $paramName -> extract \"paramName\"\n * - {$paramName} -> extract \"paramName\"\n * - prefix{$paramName}suffix -> extract \"paramName\"\n * - {-$paramName} -> extract \"paramName\" (optional)\n * - prefix{-$paramName}suffix -> extract \"paramName\" (optional)\n * - $ or {$} -> wildcard, skip validation\n */\nexport function extractParamsFromSegment(\n segment: string,\n): Array<ExtractedParam> {\n const params: Array<ExtractedParam> = []\n\n // Skip empty segments\n if (!segment || !segment.includes('$')) {\n return params\n }\n\n // Check for wildcard ($ alone or {$})\n if (segment === '$' || segment === '{$}') {\n return params // Wildcard, no param name to validate\n }\n\n // Pattern 1: Simple $paramName (entire segment starts with $)\n if (segment.startsWith('$') && !segment.includes('{')) {\n const paramName = segment.slice(1)\n if (paramName) {\n params.push({\n fullParam: segment,\n paramName,\n isOptional: false,\n isValid: VALID_PARAM_NAME_REGEX.test(paramName),\n })\n }\n return params\n }\n\n // Pattern 2: Braces pattern {$paramName} or {-$paramName} with optional prefix/suffix\n // Match patterns like: prefix{$param}suffix, {$param}, {-$param}\n const bracePattern = /\\{(-?\\$)([^}]*)\\}/g\n let match\n\n while ((match = bracePattern.exec(segment)) !== null) {\n const prefix = match[1] // \"$\" or \"-$\"\n const paramName = match[2] // The param name after $ or -$\n\n if (!paramName) {\n // This is a wildcard {$} or {-$}, skip\n continue\n }\n\n const isOptional = prefix === '-$'\n\n params.push({\n fullParam: `${prefix}${paramName}`,\n paramName,\n isOptional,\n isValid: VALID_PARAM_NAME_REGEX.test(paramName),\n })\n }\n\n return params\n}\n\n/**\n * Extracts all params from a route path.\n *\n * @param path - The route path (e.g., \"/users/$userId/posts/$postId\")\n * @returns Array of extracted params with validation info\n */\nexport function extractParamsFromPath(path: string): Array<ExtractedParam> {\n if (!path || !path.includes('$')) {\n return []\n }\n\n const segments = path.split('/')\n const allParams: Array<ExtractedParam> = []\n\n for (const segment of segments) {\n const params = extractParamsFromSegment(segment)\n allParams.push(...params)\n }\n\n return allParams\n}\n\n/**\n * Validates a single param name.\n *\n * @param paramName - The param name to validate (without $ prefix)\n * @returns Whether the param name is valid\n */\nexport function isValidParamName(paramName: string): boolean {\n return VALID_PARAM_NAME_REGEX.test(paramName)\n}\n\n/**\n * Gets all invalid params from a route path.\n *\n * @param path - The route path\n * @returns Array of invalid param info\n */\nexport function getInvalidParams(path: string): Array<ExtractedParam> {\n const params = extractParamsFromPath(path)\n return params.filter((p) => !p.isValid)\n}\n"],"names":[],"mappings":";AAwBO,SAAS,yBACd,SACuB;AACvB,QAAM,SAAgC,CAAA;AAGtC,MAAI,CAAC,WAAW,CAAC,QAAQ,SAAS,GAAG,GAAG;AACtC,WAAO;AAAA,EACT;AAGA,MAAI,YAAY,OAAO,YAAY,OAAO;AACxC,WAAO;AAAA,EACT;AAGA,MAAI,QAAQ,WAAW,GAAG,KAAK,CAAC,QAAQ,SAAS,GAAG,GAAG;AACrD,UAAM,YAAY,QAAQ,MAAM,CAAC;AACjC,QAAI,WAAW;AACb,aAAO,KAAK;AAAA,QACV,WAAW;AAAA,QACX;AAAA,QACA,YAAY;AAAA,QACZ,SAAS,uBAAuB,KAAK,SAAS;AAAA,MAAA,CAC/C;AAAA,IACH;AACA,WAAO;AAAA,EACT;AAIA,QAAM,eAAe;AACrB,MAAI;AAEJ,UAAQ,QAAQ,aAAa,KAAK,OAAO,OAAO,MAAM;AACpD,UAAM,SAAS,MAAM,CAAC;AACtB,UAAM,YAAY,MAAM,CAAC;AAEzB,QAAI,CAAC,WAAW;AAEd;AAAA,IACF;AAEA,UAAM,aAAa,WAAW;AAE9B,WAAO,KAAK;AAAA,MACV,WAAW,GAAG,MAAM,GAAG,SAAS;AAAA,MAChC;AAAA,MACA;AAAA,MACA,SAAS,uBAAuB,KAAK,SAAS;AAAA,IAAA,CAC/C;AAAA,EACH;AAEA,SAAO;AACT;AAQO,SAAS,sBAAsB,MAAqC;AACzE,MAAI,CAAC,QAAQ,CAAC,KAAK,SAAS,GAAG,GAAG;AAChC,WAAO,CAAA;AAAA,EACT;AAEA,QAAM,WAAW,KAAK,MAAM,GAAG;AAC/B,QAAM,YAAmC,CAAA;AAEzC,aAAW,WAAW,UAAU;AAC9B,UAAM,SAAS,yBAAyB,OAAO;AAC/C,cAAU,KAAK,GAAG,MAAM;AAAA,EAC1B;AAEA,SAAO;AACT;AAkBO,SAAS,iBAAiB,MAAqC;AACpE,QAAM,SAAS,sBAAsB,IAAI;AACzC,SAAO,OAAO,OAAO,CAAC,MAAM,CAAC,EAAE,OAAO;AACxC;"}
package/dist/esm/rules.js CHANGED
@@ -1,5 +1,7 @@
1
- import { rule, name } from "./rules/create-route-property-order/create-route-property-order.rule.js";
1
+ import { rule as rule$1, name as name$1 } from "./rules/create-route-property-order/create-route-property-order.rule.js";
2
+ import { rule, name } from "./rules/route-param-names/route-param-names.rule.js";
2
3
  const rules = {
4
+ [name$1]: rule$1,
3
5
  [name]: rule
4
6
  };
5
7
  export {
@@ -1 +1 @@
1
- {"version":3,"file":"rules.js","sources":["../../src/rules.ts"],"sourcesContent":["import * as createRoutePropertyOrder from './rules/create-route-property-order/create-route-property-order.rule'\nimport type { ESLintUtils } from '@typescript-eslint/utils'\nimport type { ExtraRuleDocs } from './types'\n\nexport const rules: Record<\n string,\n ESLintUtils.RuleModule<\n string,\n ReadonlyArray<unknown>,\n ExtraRuleDocs,\n ESLintUtils.RuleListener\n >\n> = {\n [createRoutePropertyOrder.name]: createRoutePropertyOrder.rule,\n}\n"],"names":["createRoutePropertyOrder.name","createRoutePropertyOrder.rule"],"mappings":";AAIO,MAAM,QAQT;AAAA,EACF,CAACA,IAA6B,GAAGC;AACnC;"}
1
+ {"version":3,"file":"rules.js","sources":["../../src/rules.ts"],"sourcesContent":["import * as createRoutePropertyOrder from './rules/create-route-property-order/create-route-property-order.rule'\nimport * as routeParamNames from './rules/route-param-names/route-param-names.rule'\nimport type { ESLintUtils } from '@typescript-eslint/utils'\nimport type { ExtraRuleDocs } from './types'\n\nexport const rules: Record<\n string,\n ESLintUtils.RuleModule<\n string,\n ReadonlyArray<unknown>,\n ExtraRuleDocs,\n ESLintUtils.RuleListener\n >\n> = {\n [createRoutePropertyOrder.name]: createRoutePropertyOrder.rule,\n [routeParamNames.name]: routeParamNames.rule,\n}\n"],"names":["createRoutePropertyOrder.name","createRoutePropertyOrder.rule","routeParamNames.name","routeParamNames.rule"],"mappings":";;AAKO,MAAM,QAQT;AAAA,EACF,CAACA,MAA6B,GAAGC;AAAAA,EACjC,CAACC,IAAoB,GAAGC;AAC1B;"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tanstack/eslint-plugin-router",
3
- "version": "1.141.0",
3
+ "version": "1.155.0",
4
4
  "description": "ESLint plugin for TanStack Router",
5
5
  "author": "Manuel Schiller",
6
6
  "license": "MIT",
@@ -0,0 +1,271 @@
1
+ import { RuleTester } from '@typescript-eslint/rule-tester'
2
+
3
+ import { name, rule } from '../rules/route-param-names/route-param-names.rule'
4
+
5
+ const ruleTester = new RuleTester()
6
+
7
+ ruleTester.run(name, rule, {
8
+ valid: [
9
+ // Valid param names - simple $param format
10
+ {
11
+ name: 'valid simple param: $userId',
12
+ code: `
13
+ import { createFileRoute } from '@tanstack/react-router'
14
+ const Route = createFileRoute('/users/$userId')({})
15
+ `,
16
+ },
17
+ {
18
+ name: 'valid simple param: $id',
19
+ code: `
20
+ import { createFileRoute } from '@tanstack/react-router'
21
+ const Route = createFileRoute('/posts/$id')({})
22
+ `,
23
+ },
24
+ {
25
+ name: 'valid simple param: $_id (underscore prefix)',
26
+ code: `
27
+ import { createFileRoute } from '@tanstack/react-router'
28
+ const Route = createFileRoute('/items/$_id')({})
29
+ `,
30
+ },
31
+ {
32
+ name: 'valid simple param: $$var (dollar prefix)',
33
+ code: `
34
+ import { createFileRoute } from '@tanstack/react-router'
35
+ const Route = createFileRoute('/data/$$var')({})
36
+ `,
37
+ },
38
+ {
39
+ name: 'valid param with numbers: $user123',
40
+ code: `
41
+ import { createFileRoute } from '@tanstack/react-router'
42
+ const Route = createFileRoute('/users/$user123')({})
43
+ `,
44
+ },
45
+
46
+ // Valid param names - braces format {$param}
47
+ {
48
+ name: 'valid braces param: {$userName}',
49
+ code: `
50
+ import { createFileRoute } from '@tanstack/react-router'
51
+ const Route = createFileRoute('/users/{$userName}')({})
52
+ `,
53
+ },
54
+ {
55
+ name: 'valid braces param with prefix/suffix: prefix{$id}suffix',
56
+ code: `
57
+ import { createFileRoute } from '@tanstack/react-router'
58
+ const Route = createFileRoute('/items/item-{$id}-details')({})
59
+ `,
60
+ },
61
+
62
+ // Valid optional params - {-$param}
63
+ {
64
+ name: 'valid optional param: {-$optional}',
65
+ code: `
66
+ import { createFileRoute } from '@tanstack/react-router'
67
+ const Route = createFileRoute('/search/{-$query}')({})
68
+ `,
69
+ },
70
+ {
71
+ name: 'valid optional param with prefix/suffix: prefix{-$opt}suffix',
72
+ code: `
73
+ import { createFileRoute } from '@tanstack/react-router'
74
+ const Route = createFileRoute('/filter/by-{-$category}-items')({})
75
+ `,
76
+ },
77
+
78
+ // Wildcards - should be skipped (no validation)
79
+ {
80
+ name: 'wildcard: $ alone',
81
+ code: `
82
+ import { createFileRoute } from '@tanstack/react-router'
83
+ const Route = createFileRoute('/files/$')({})
84
+ `,
85
+ },
86
+ {
87
+ name: 'wildcard: {$}',
88
+ code: `
89
+ import { createFileRoute } from '@tanstack/react-router'
90
+ const Route = createFileRoute('/catch/{$}')({})
91
+ `,
92
+ },
93
+
94
+ // Multiple valid params
95
+ {
96
+ name: 'multiple valid params in path',
97
+ code: `
98
+ import { createFileRoute } from '@tanstack/react-router'
99
+ const Route = createFileRoute('/users/$userId/posts/$postId')({})
100
+ `,
101
+ },
102
+
103
+ // createRoute with path property
104
+ {
105
+ name: 'createRoute with valid param in path property',
106
+ code: `
107
+ import { createRoute } from '@tanstack/react-router'
108
+ const Route = createRoute({ path: '/users/$userId' })
109
+ `,
110
+ },
111
+
112
+ // createLazyFileRoute
113
+ {
114
+ name: 'createLazyFileRoute with valid param',
115
+ code: `
116
+ import { createLazyFileRoute } from '@tanstack/react-router'
117
+ const Route = createLazyFileRoute('/users/$userId')({})
118
+ `,
119
+ },
120
+
121
+ // createLazyRoute
122
+ {
123
+ name: 'createLazyRoute with valid param',
124
+ code: `
125
+ import { createLazyRoute } from '@tanstack/react-router'
126
+ const Route = createLazyRoute('/users/$userId')({})
127
+ `,
128
+ },
129
+
130
+ // No params - should pass
131
+ {
132
+ name: 'no params in path',
133
+ code: `
134
+ import { createFileRoute } from '@tanstack/react-router'
135
+ const Route = createFileRoute('/users/list')({})
136
+ `,
137
+ },
138
+
139
+ // Not from tanstack router - should be ignored
140
+ {
141
+ name: 'non-tanstack import should be ignored',
142
+ code: `
143
+ import { createFileRoute } from 'other-router'
144
+ const Route = createFileRoute('/users/$123invalid')({})
145
+ `,
146
+ },
147
+ ],
148
+
149
+ invalid: [
150
+ // Invalid param names - starts with number
151
+ {
152
+ name: 'invalid param starting with number: $123',
153
+ code: `
154
+ import { createFileRoute } from '@tanstack/react-router'
155
+ const Route = createFileRoute('/users/$123')({})
156
+ `,
157
+ errors: [{ messageId: 'invalidParamName', data: { paramName: '123' } }],
158
+ },
159
+ {
160
+ name: 'invalid param starting with number: $1user',
161
+ code: `
162
+ import { createFileRoute } from '@tanstack/react-router'
163
+ const Route = createFileRoute('/users/$1user')({})
164
+ `,
165
+ errors: [{ messageId: 'invalidParamName', data: { paramName: '1user' } }],
166
+ },
167
+
168
+ // Invalid param names - contains hyphen
169
+ {
170
+ name: 'invalid param with hyphen: $user-name',
171
+ code: `
172
+ import { createFileRoute } from '@tanstack/react-router'
173
+ const Route = createFileRoute('/users/$user-name')({})
174
+ `,
175
+ errors: [
176
+ { messageId: 'invalidParamName', data: { paramName: 'user-name' } },
177
+ ],
178
+ },
179
+
180
+ // Invalid param names - contains dot
181
+ {
182
+ name: 'invalid param with dot: {$my.param}',
183
+ code: `
184
+ import { createFileRoute } from '@tanstack/react-router'
185
+ const Route = createFileRoute('/users/{$my.param}')({})
186
+ `,
187
+ errors: [
188
+ { messageId: 'invalidParamName', data: { paramName: 'my.param' } },
189
+ ],
190
+ },
191
+
192
+ // Invalid param names - contains space
193
+ {
194
+ name: 'invalid param with space: {$param name}',
195
+ code: `
196
+ import { createFileRoute } from '@tanstack/react-router'
197
+ const Route = createFileRoute('/users/{$param name}')({})
198
+ `,
199
+ errors: [
200
+ { messageId: 'invalidParamName', data: { paramName: 'param name' } },
201
+ ],
202
+ },
203
+
204
+ // Invalid optional param
205
+ {
206
+ name: 'invalid optional param: {-$123invalid}',
207
+ code: `
208
+ import { createFileRoute } from '@tanstack/react-router'
209
+ const Route = createFileRoute('/search/{-$123invalid}')({})
210
+ `,
211
+ errors: [
212
+ { messageId: 'invalidParamName', data: { paramName: '123invalid' } },
213
+ ],
214
+ },
215
+
216
+ // Multiple invalid params
217
+ {
218
+ name: 'multiple invalid params in path',
219
+ code: `
220
+ import { createFileRoute } from '@tanstack/react-router'
221
+ const Route = createFileRoute('/users/$1id/posts/$post-id')({})
222
+ `,
223
+ errors: [
224
+ { messageId: 'invalidParamName', data: { paramName: '1id' } },
225
+ { messageId: 'invalidParamName', data: { paramName: 'post-id' } },
226
+ ],
227
+ },
228
+
229
+ // createRoute with invalid path property
230
+ {
231
+ name: 'createRoute with invalid param in path property',
232
+ code: `
233
+ import { createRoute } from '@tanstack/react-router'
234
+ const Route = createRoute({ path: '/users/$123' })
235
+ `,
236
+ errors: [{ messageId: 'invalidParamName', data: { paramName: '123' } }],
237
+ },
238
+
239
+ // createLazyFileRoute with invalid param
240
+ {
241
+ name: 'createLazyFileRoute with invalid param',
242
+ code: `
243
+ import { createLazyFileRoute } from '@tanstack/react-router'
244
+ const Route = createLazyFileRoute('/users/$user-id')({})
245
+ `,
246
+ errors: [
247
+ { messageId: 'invalidParamName', data: { paramName: 'user-id' } },
248
+ ],
249
+ },
250
+
251
+ // createLazyRoute with invalid param
252
+ {
253
+ name: 'createLazyRoute with invalid param',
254
+ code: `
255
+ import { createLazyRoute } from '@tanstack/react-router'
256
+ const Route = createLazyRoute('/users/$1abc')({})
257
+ `,
258
+ errors: [{ messageId: 'invalidParamName', data: { paramName: '1abc' } }],
259
+ },
260
+
261
+ // Invalid braces param with prefix/suffix
262
+ {
263
+ name: 'invalid braces param with prefix/suffix',
264
+ code: `
265
+ import { createFileRoute } from '@tanstack/react-router'
266
+ const Route = createFileRoute('/items/item-{$123}-details')({})
267
+ `,
268
+ errors: [{ messageId: 'invalidParamName', data: { paramName: '123' } }],
269
+ },
270
+ ],
271
+ })
@@ -0,0 +1,174 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import {
3
+ extractParamsFromPath,
4
+ extractParamsFromSegment,
5
+ getInvalidParams,
6
+ isValidParamName,
7
+ } from '../rules/route-param-names/route-param-names.utils'
8
+
9
+ describe('isValidParamName', () => {
10
+ it('should return true for valid param names', () => {
11
+ expect(isValidParamName('userId')).toBe(true)
12
+ expect(isValidParamName('id')).toBe(true)
13
+ expect(isValidParamName('_id')).toBe(true)
14
+ expect(isValidParamName('$var')).toBe(true)
15
+ expect(isValidParamName('user123')).toBe(true)
16
+ expect(isValidParamName('_')).toBe(true)
17
+ expect(isValidParamName('$')).toBe(true)
18
+ expect(isValidParamName('ABC')).toBe(true)
19
+ expect(isValidParamName('camelCase')).toBe(true)
20
+ expect(isValidParamName('PascalCase')).toBe(true)
21
+ expect(isValidParamName('snake_case')).toBe(true)
22
+ expect(isValidParamName('$$double')).toBe(true)
23
+ expect(isValidParamName('__double')).toBe(true)
24
+ })
25
+
26
+ it('should return false for invalid param names', () => {
27
+ expect(isValidParamName('123')).toBe(false)
28
+ expect(isValidParamName('1user')).toBe(false)
29
+ expect(isValidParamName('user-name')).toBe(false)
30
+ expect(isValidParamName('user.name')).toBe(false)
31
+ expect(isValidParamName('user name')).toBe(false)
32
+ expect(isValidParamName('')).toBe(false)
33
+ expect(isValidParamName('user@name')).toBe(false)
34
+ expect(isValidParamName('user#name')).toBe(false)
35
+ expect(isValidParamName('-user')).toBe(false)
36
+ })
37
+ })
38
+
39
+ describe('extractParamsFromSegment', () => {
40
+ it('should return empty array for segments without $', () => {
41
+ expect(extractParamsFromSegment('')).toEqual([])
42
+ expect(extractParamsFromSegment('users')).toEqual([])
43
+ expect(extractParamsFromSegment('static-segment')).toEqual([])
44
+ })
45
+
46
+ it('should skip wildcard segments', () => {
47
+ expect(extractParamsFromSegment('$')).toEqual([])
48
+ expect(extractParamsFromSegment('{$}')).toEqual([])
49
+ })
50
+
51
+ it('should extract simple $param format', () => {
52
+ const result = extractParamsFromSegment('$userId')
53
+ expect(result).toHaveLength(1)
54
+ expect(result[0]).toEqual({
55
+ fullParam: '$userId',
56
+ paramName: 'userId',
57
+ isOptional: false,
58
+ isValid: true,
59
+ })
60
+ })
61
+
62
+ it('should extract braces {$param} format', () => {
63
+ const result = extractParamsFromSegment('{$userId}')
64
+ expect(result).toHaveLength(1)
65
+ expect(result[0]).toEqual({
66
+ fullParam: '$userId',
67
+ paramName: 'userId',
68
+ isOptional: false,
69
+ isValid: true,
70
+ })
71
+ })
72
+
73
+ it('should extract braces with prefix/suffix', () => {
74
+ const result = extractParamsFromSegment('prefix{$id}suffix')
75
+ expect(result).toHaveLength(1)
76
+ expect(result[0]).toEqual({
77
+ fullParam: '$id',
78
+ paramName: 'id',
79
+ isOptional: false,
80
+ isValid: true,
81
+ })
82
+ })
83
+
84
+ it('should extract optional {-$param} format', () => {
85
+ const result = extractParamsFromSegment('{-$optional}')
86
+ expect(result).toHaveLength(1)
87
+ expect(result[0]).toEqual({
88
+ fullParam: '-$optional',
89
+ paramName: 'optional',
90
+ isOptional: true,
91
+ isValid: true,
92
+ })
93
+ })
94
+
95
+ it('should extract optional with prefix/suffix', () => {
96
+ const result = extractParamsFromSegment('pre{-$opt}post')
97
+ expect(result).toHaveLength(1)
98
+ expect(result[0]).toEqual({
99
+ fullParam: '-$opt',
100
+ paramName: 'opt',
101
+ isOptional: true,
102
+ isValid: true,
103
+ })
104
+ })
105
+
106
+ it('should mark invalid param names', () => {
107
+ const result = extractParamsFromSegment('$123invalid')
108
+ expect(result).toHaveLength(1)
109
+ expect(result[0]?.isValid).toBe(false)
110
+ expect(result[0]?.paramName).toBe('123invalid')
111
+ })
112
+
113
+ it('should mark hyphenated param names as invalid', () => {
114
+ const result = extractParamsFromSegment('$user-name')
115
+ expect(result).toHaveLength(1)
116
+ expect(result[0]?.isValid).toBe(false)
117
+ expect(result[0]?.paramName).toBe('user-name')
118
+ })
119
+ })
120
+
121
+ describe('extractParamsFromPath', () => {
122
+ it('should return empty array for paths without params', () => {
123
+ expect(extractParamsFromPath('')).toEqual([])
124
+ expect(extractParamsFromPath('/')).toEqual([])
125
+ expect(extractParamsFromPath('/users/list')).toEqual([])
126
+ })
127
+
128
+ it('should extract single param from path', () => {
129
+ const result = extractParamsFromPath('/users/$userId')
130
+ expect(result).toHaveLength(1)
131
+ expect(result[0]?.paramName).toBe('userId')
132
+ })
133
+
134
+ it('should extract multiple params from path', () => {
135
+ const result = extractParamsFromPath('/users/$userId/posts/$postId')
136
+ expect(result).toHaveLength(2)
137
+ expect(result[0]?.paramName).toBe('userId')
138
+ expect(result[1]?.paramName).toBe('postId')
139
+ })
140
+
141
+ it('should extract params with various formats', () => {
142
+ const result = extractParamsFromPath(
143
+ '/a/$simple/b/{$braces}/c/{-$optional}',
144
+ )
145
+ expect(result).toHaveLength(3)
146
+ expect(result[0]?.paramName).toBe('simple')
147
+ expect(result[0]?.isOptional).toBe(false)
148
+ expect(result[1]?.paramName).toBe('braces')
149
+ expect(result[1]?.isOptional).toBe(false)
150
+ expect(result[2]?.paramName).toBe('optional')
151
+ expect(result[2]?.isOptional).toBe(true)
152
+ })
153
+ })
154
+
155
+ describe('getInvalidParams', () => {
156
+ it('should return empty array for valid params', () => {
157
+ expect(getInvalidParams('/users/$userId')).toEqual([])
158
+ expect(getInvalidParams('/users/$_id')).toEqual([])
159
+ expect(getInvalidParams('/users/$$var')).toEqual([])
160
+ })
161
+
162
+ it('should return invalid params only', () => {
163
+ const result = getInvalidParams('/users/$123/posts/$validId')
164
+ expect(result).toHaveLength(1)
165
+ expect(result[0]?.paramName).toBe('123')
166
+ })
167
+
168
+ it('should return all invalid params', () => {
169
+ const result = getInvalidParams('/users/$1id/posts/$post-id')
170
+ expect(result).toHaveLength(2)
171
+ expect(result[0]?.paramName).toBe('1id')
172
+ expect(result[1]?.paramName).toBe('post-id')
173
+ })
174
+ })
package/src/index.ts CHANGED
@@ -26,6 +26,7 @@ Object.assign(plugin.configs, {
26
26
  plugins: ['@tanstack/eslint-plugin-router'],
27
27
  rules: {
28
28
  '@tanstack/router/create-route-property-order': 'warn',
29
+ '@tanstack/router/route-param-names': 'error',
29
30
  },
30
31
  },
31
32
  'flat/recommended': [
@@ -35,6 +36,7 @@ Object.assign(plugin.configs, {
35
36
  },
36
37
  rules: {
37
38
  '@tanstack/router/create-route-property-order': 'warn',
39
+ '@tanstack/router/route-param-names': 'error',
38
40
  },
39
41
  },
40
42
  ],
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Functions where the path is passed as the first argument (string literal)
3
+ * e.g., createFileRoute('/path/$param')(...)
4
+ */
5
+ export const pathAsFirstArgFunctions = [
6
+ 'createFileRoute',
7
+ 'createLazyFileRoute',
8
+ 'createLazyRoute',
9
+ ] as const
10
+
11
+ export type PathAsFirstArgFunction = (typeof pathAsFirstArgFunctions)[number]
12
+
13
+ /**
14
+ * Functions where the path is a property in the options object
15
+ * e.g., createRoute({ path: '/path/$param' })
16
+ */
17
+ export const pathAsPropertyFunctions = ['createRoute'] as const
18
+
19
+ export type PathAsPropertyFunction = (typeof pathAsPropertyFunctions)[number]
20
+
21
+ /**
22
+ * All route functions that need param name validation
23
+ */
24
+ export const allRouteFunctions = [
25
+ ...pathAsFirstArgFunctions,
26
+ ...pathAsPropertyFunctions,
27
+ ] as const
28
+
29
+ export type RouteFunction = (typeof allRouteFunctions)[number]
30
+
31
+ /**
32
+ * Regex for valid JavaScript identifier (param name)
33
+ * Must start with letter, underscore, or dollar sign
34
+ * Can contain letters, numbers, underscores, or dollar signs
35
+ */
36
+ export const VALID_PARAM_NAME_REGEX = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/
@@ -0,0 +1,127 @@
1
+ import { AST_NODE_TYPES, ESLintUtils } from '@typescript-eslint/utils'
2
+
3
+ import { getDocsUrl } from '../../utils/get-docs-url'
4
+ import { detectTanstackRouterImports } from '../../utils/detect-router-imports'
5
+ import { getInvalidParams } from './route-param-names.utils'
6
+ import { pathAsFirstArgFunctions, pathAsPropertyFunctions } from './constants'
7
+ import type { TSESTree } from '@typescript-eslint/utils'
8
+ import type { ExtraRuleDocs } from '../../types'
9
+
10
+ const createRule = ESLintUtils.RuleCreator<ExtraRuleDocs>(getDocsUrl)
11
+
12
+ const pathAsFirstArgSet = new Set<string>(pathAsFirstArgFunctions)
13
+ const pathAsPropertySet = new Set<string>(pathAsPropertyFunctions)
14
+
15
+ export const name = 'route-param-names'
16
+
17
+ export const rule = createRule({
18
+ name,
19
+ meta: {
20
+ type: 'problem',
21
+ docs: {
22
+ description: 'Ensure route param names are valid JavaScript identifiers',
23
+ recommended: 'error',
24
+ },
25
+ messages: {
26
+ invalidParamName:
27
+ 'Invalid param name "{{paramName}}" in route path. Param names must be valid JavaScript identifiers (match /[a-zA-Z_$][a-zA-Z0-9_$]*/).',
28
+ },
29
+ schema: [],
30
+ },
31
+ defaultOptions: [],
32
+
33
+ create: detectTanstackRouterImports((context, _, helpers) => {
34
+ function reportInvalidParams(node: TSESTree.Node, path: string) {
35
+ const invalidParams = getInvalidParams(path)
36
+
37
+ for (const param of invalidParams) {
38
+ context.report({
39
+ node,
40
+ messageId: 'invalidParamName',
41
+ data: { paramName: param.paramName },
42
+ })
43
+ }
44
+ }
45
+
46
+ function getStringLiteralValue(node: TSESTree.Node): string | null {
47
+ if (
48
+ node.type === AST_NODE_TYPES.Literal &&
49
+ typeof node.value === 'string'
50
+ ) {
51
+ return node.value
52
+ }
53
+ if (
54
+ node.type === AST_NODE_TYPES.TemplateLiteral &&
55
+ node.quasis.length === 1
56
+ ) {
57
+ const cooked = node.quasis[0]?.value.cooked
58
+ if (cooked != null) {
59
+ return cooked
60
+ }
61
+ }
62
+ return null
63
+ }
64
+
65
+ return {
66
+ CallExpression(node) {
67
+ // Handle direct function call: createRoute({ path: '...' })
68
+ if (node.callee.type === AST_NODE_TYPES.Identifier) {
69
+ const funcName = node.callee.name
70
+
71
+ // Skip if not imported from TanStack Router
72
+ if (!helpers.isTanstackRouterImport(node.callee)) {
73
+ return
74
+ }
75
+
76
+ // Case: createRoute({ path: '/path/$param' }) or createRoute({ 'path': '/path/$param' })
77
+ if (pathAsPropertySet.has(funcName)) {
78
+ const arg = node.arguments[0]
79
+ if (arg?.type === AST_NODE_TYPES.ObjectExpression) {
80
+ for (const prop of arg.properties) {
81
+ if (prop.type === AST_NODE_TYPES.Property) {
82
+ const isPathKey =
83
+ (prop.key.type === AST_NODE_TYPES.Identifier &&
84
+ prop.key.name === 'path') ||
85
+ (prop.key.type === AST_NODE_TYPES.Literal &&
86
+ prop.key.value === 'path')
87
+ if (isPathKey) {
88
+ const pathValue = getStringLiteralValue(prop.value)
89
+ if (pathValue) {
90
+ reportInvalidParams(prop.value, pathValue)
91
+ }
92
+ }
93
+ }
94
+ }
95
+ }
96
+ return
97
+ }
98
+ }
99
+
100
+ // Handle curried function call: createFileRoute('/path')({ ... })
101
+ if (node.callee.type === AST_NODE_TYPES.CallExpression) {
102
+ const innerCall = node.callee
103
+
104
+ if (innerCall.callee.type === AST_NODE_TYPES.Identifier) {
105
+ const funcName = innerCall.callee.name
106
+
107
+ // Skip if not imported from TanStack Router
108
+ if (!helpers.isTanstackRouterImport(innerCall.callee)) {
109
+ return
110
+ }
111
+
112
+ // Case: createFileRoute('/path/$param')(...) or similar
113
+ if (pathAsFirstArgSet.has(funcName)) {
114
+ const pathArg = innerCall.arguments[0]
115
+ if (pathArg) {
116
+ const pathValue = getStringLiteralValue(pathArg)
117
+ if (pathValue) {
118
+ reportInvalidParams(pathArg, pathValue)
119
+ }
120
+ }
121
+ }
122
+ }
123
+ }
124
+ },
125
+ }
126
+ }),
127
+ })