@tanstack/eslint-plugin-router 1.154.7 → 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.
- package/dist/cjs/index.cjs +4 -2
- package/dist/cjs/index.cjs.map +1 -1
- package/dist/cjs/rules/route-param-names/constants.cjs +13 -0
- package/dist/cjs/rules/route-param-names/constants.cjs.map +1 -0
- package/dist/cjs/rules/route-param-names/constants.d.cts +23 -0
- package/dist/cjs/rules/route-param-names/route-param-names.rule.cjs +98 -0
- package/dist/cjs/rules/route-param-names/route-param-names.rule.cjs.map +1 -0
- package/dist/cjs/rules/route-param-names/route-param-names.rule.d.cts +4 -0
- package/dist/cjs/rules/route-param-names/route-param-names.utils.cjs +61 -0
- package/dist/cjs/rules/route-param-names/route-param-names.utils.cjs.map +1 -0
- package/dist/cjs/rules/route-param-names/route-param-names.utils.d.cts +43 -0
- package/dist/cjs/rules.cjs +3 -1
- package/dist/cjs/rules.cjs.map +1 -1
- package/dist/esm/index.js +4 -2
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/rules/route-param-names/constants.d.ts +23 -0
- package/dist/esm/rules/route-param-names/constants.js +13 -0
- package/dist/esm/rules/route-param-names/constants.js.map +1 -0
- package/dist/esm/rules/route-param-names/route-param-names.rule.d.ts +4 -0
- package/dist/esm/rules/route-param-names/route-param-names.rule.js +98 -0
- package/dist/esm/rules/route-param-names/route-param-names.rule.js.map +1 -0
- package/dist/esm/rules/route-param-names/route-param-names.utils.d.ts +43 -0
- package/dist/esm/rules/route-param-names/route-param-names.utils.js +61 -0
- package/dist/esm/rules/route-param-names/route-param-names.utils.js.map +1 -0
- package/dist/esm/rules.js +3 -1
- package/dist/esm/rules.js.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/route-param-names.rule.test.ts +271 -0
- package/src/__tests__/route-param-names.utils.test.ts +174 -0
- package/src/index.ts +2 -0
- package/src/rules/route-param-names/constants.ts +36 -0
- package/src/rules/route-param-names/route-param-names.rule.ts +127 -0
- package/src/rules/route-param-names/route-param-names.utils.ts +122 -0
- 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 {
|
package/dist/esm/rules.js.map
CHANGED
|
@@ -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":"
|
|
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
|
@@ -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
|
+
})
|