Skip to content

Commit b755c8d

Browse files
committed
feat!: add support for route groups + arbitrary modes
1 parent 52a83bf commit b755c8d

File tree

4 files changed

+456
-225
lines changed

4 files changed

+456
-225
lines changed

src/converters.ts

Lines changed: 59 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1-
import type { ParsedPath } from './parse'
21
import escapeStringRegexp from 'escape-string-regexp'
32

43
import { encodePath, joinURL } from 'ufo'
4+
import type { ParsedPathSegment } from './parse'
55
import { parsePath } from './parse'
66

77
/**
@@ -17,12 +17,15 @@ import { parsePath } from './parse'
1717
/**
1818
* TODO: need to implement protection logging + fall back to what radix3 supports.
1919
*/
20-
export function toRadix3(filePath: string | ParsedPath) {
21-
const segments = typeof filePath === 'string' ? parsePath(filePath) : filePath
20+
export function toRadix3(filePath: string | ParsedPathSegment[]) {
21+
const segments = typeof filePath === 'string' ? parsePath(filePath).segments : filePath
2222

2323
let route = '/'
2424

2525
for (const segment of segments) {
26+
if (segment.every(token => token.type === 'group'))
27+
continue
28+
2629
let radixSegment = ''
2730
for (const token of segment) {
2831
if (token.type === 'static')
@@ -39,36 +42,52 @@ export function toRadix3(filePath: string | ParsedPath) {
3942
}
4043

4144
// If a segment has value '' we skip adding it entirely
42-
if (route)
45+
if (radixSegment)
4346
route = joinURL(route, radixSegment)
4447
}
4548

4649
return route
4750
}
4851

49-
export function toVueRouter4(filePath: string | ParsedPath) {
50-
const segments = typeof filePath === 'string' ? parsePath(filePath) : filePath
52+
export function toVueRouter4(filePath: string | ParsedPathSegment[]) {
53+
const segments = typeof filePath === 'string' ? parsePath(filePath).segments : filePath
5154

5255
let path = '/'
5356

54-
for (const segment of segments) {
57+
for (let i = 0; i < segments.length; i++) {
58+
const segment = segments[i]
59+
60+
// Skip group-only segments as they don't contribute to the URL path
61+
if (segment.every(token => token.type === 'group'))
62+
continue
63+
64+
const hasSucceedingSegment = i < segments.length - 1
65+
5566
let pathSegment = ''
5667
for (const token of segment) {
68+
if (token.type === 'group')
69+
continue
5770
if (token.type === 'static') {
5871
pathSegment += encodePath(token.value).replace(/:/g, '\\:')
5972
continue
6073
}
61-
if (token.type === 'dynamic')
74+
if (token.type === 'dynamic') {
6275
pathSegment += `:${token.value}()`
63-
64-
if (token.type === 'optional')
76+
continue
77+
}
78+
if (token.type === 'optional') {
6579
pathSegment += `:${token.value}?`
66-
67-
if (token.type === 'catchall')
68-
pathSegment += `:${token.value}(.*)*`
80+
continue
81+
}
82+
if (token.type === 'catchall') {
83+
pathSegment += hasSucceedingSegment ? `:${token.value}([^/]*)*` : `:${token.value}(.*)*`
84+
continue
85+
}
6986
}
7087

71-
path = joinURL(path, pathSegment)
88+
// Only join if pathSegment is not empty
89+
if (pathSegment)
90+
path = joinURL(path, pathSegment)
7291
}
7392

7493
return {
@@ -79,28 +98,41 @@ export function toVueRouter4(filePath: string | ParsedPath) {
7998
function sanitizeCaptureGroup(captureGroup: string) {
8099
return captureGroup.replace(/^(\d)/, '_$1').replace(/\./g, '')
81100
}
82-
export function toRegExp(filePath: string | ParsedPath) {
83-
const segments = typeof filePath === 'string' ? parsePath(filePath) : filePath
101+
export function toRegExp(filePath: string | ParsedPathSegment[]) {
102+
const segments = typeof filePath === 'string' ? parsePath(filePath).segments : filePath
84103

104+
const keys: string[] = []
85105
let sourceRE = '\\/'
86106

87107
for (const segment of segments) {
108+
if (segment.every(token => token.type === 'group'))
109+
continue
110+
88111
let reSegment = ''
89112
for (const token of segment) {
90113
if (token.type === 'static')
91114
reSegment += escapeStringRegexp(token.value)
92115

93-
if (token.type === 'dynamic')
94-
reSegment += `(?<${sanitizeCaptureGroup(token.value)}>[^/]+)`
116+
if (token.type === 'dynamic') {
117+
const key = sanitizeCaptureGroup(token.value)
118+
keys.push(key)
119+
reSegment += `(?<${key}>[^/]+)`
120+
}
95121

96-
if (token.type === 'optional')
97-
reSegment += `(?<${sanitizeCaptureGroup(token.value)}>[^/]*)`
122+
if (token.type === 'optional') {
123+
const key = sanitizeCaptureGroup(token.value)
124+
keys.push(key)
125+
reSegment += `(?<${key}>[^/]*)`
126+
}
98127

99-
if (token.type === 'catchall')
100-
reSegment += `(?<${sanitizeCaptureGroup(token.value)}>.*)`
128+
if (token.type === 'catchall') {
129+
const key = sanitizeCaptureGroup(token.value)
130+
keys.push(key)
131+
reSegment += `(?<${key}>.*)`
132+
}
101133
}
102134

103-
if (segment.every(token => token.type === 'optional' || token.type === 'catchall')) {
135+
if (segment.every(token => token.type === 'optional' || token.type === 'catchall' || token.type === 'group')) {
104136
sourceRE += `(?:${reSegment}\\/?)`
105137
}
106138
else if (reSegment) {
@@ -112,5 +144,8 @@ export function toRegExp(filePath: string | ParsedPath) {
112144
// make final slash optional
113145
sourceRE += '?'
114146

115-
return new RegExp(sourceRE)
147+
return {
148+
pattern: new RegExp(sourceRE),
149+
keys,
150+
}
116151
}

src/parse.ts

Lines changed: 73 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,27 +16,72 @@ export interface ParsePathOptions {
1616
*/
1717
extensions?: string[]
1818
postfix?: string
19+
/**
20+
* Warn about invalid characters in dynamic parameters
21+
*/
22+
warn?: (message: string) => void
23+
/**
24+
* List of mode extensions to detect (e.g., ['client', 'server', 'vapor'])
25+
* These will be detected as `.mode` suffixes before the file extension
26+
* If not provided, no mode detection will be performed
27+
*/
28+
modes?: string[]
1929
}
2030

2131
export function parsePath(filePath: string, options: ParsePathOptions = {}): ParsedPath {
2232
// remove file extensions (allow-listed if `options.extensions` is specified)
2333
const EXT_RE = options.extensions ? new RegExp(`\\.(${options.extensions.join('|')})$`) : /\.\w+$/
2434
filePath = filePath.replace(EXT_RE, '')
2535

36+
// detect modes
37+
const modes: string[] = []
38+
const supportedModes = options.modes || [] // no default modes
39+
40+
// Extract modes from right to left to handle multiple modes like "file.client.vapor"
41+
let remainingPath = filePath
42+
let foundMode = true
43+
44+
while (foundMode) {
45+
foundMode = false
46+
for (const mode of supportedModes) {
47+
const modePattern = new RegExp(`\\.${mode}$`)
48+
if (modePattern.test(remainingPath)) {
49+
modes.unshift(mode) // Add to front to maintain left-to-right order
50+
remainingPath = remainingPath.replace(modePattern, '')
51+
foundMode = true
52+
break
53+
}
54+
}
55+
}
56+
57+
filePath = remainingPath
58+
2659
// add leading slash and remove trailing slash: test/ -> /test
2760
const segments = withoutLeadingSlash(withoutTrailingSlash(filePath)).split('/')
2861

29-
return segments.map(s => parseSegment(s))
62+
return {
63+
segments: segments.map(s => parseSegment(s, filePath, options.warn)),
64+
modes: modes.length > 0 ? modes : undefined,
65+
}
3066
}
3167

3268
const PARAM_CHAR_RE = /[\w.]/
3369

34-
export type SegmentType = 'static' | 'dynamic' | 'optional' | 'catchall'
70+
export type SegmentType = 'static' | 'dynamic' | 'optional' | 'catchall' | 'group'
3571
export interface ParsedPathSegmentToken { type: SegmentType, value: string }
3672
export type ParsedPathSegment = Array<ParsedPathSegmentToken>
37-
export type ParsedPath = ParsedPathSegment[]
73+
export interface ParsedPath {
74+
/**
75+
* The parsed segments of the file path
76+
*/
77+
segments: ParsedPathSegment[]
78+
/**
79+
* The detected modes from the file path (e.g., ['client', 'vapor'])
80+
*/
81+
modes?: string[]
82+
}
3883

39-
export function parseSegment(segment: string) {
84+
export function parseSegment(segment: string, absolutePath?: string, warn?: (message: string) => void) {
4085
type SegmentParserState = 'initial' | SegmentType
4186
let state: SegmentParserState = 'initial'
4287
let i = 0
@@ -67,6 +112,9 @@ export function parseSegment(segment: string) {
67112
if (c === '[') {
68113
state = 'dynamic'
69114
}
115+
else if (c === '(') {
116+
state = 'group'
117+
}
70118
else {
71119
i--
72120
state = 'static'
@@ -78,6 +126,10 @@ export function parseSegment(segment: string) {
78126
consumeBuffer()
79127
state = 'dynamic'
80128
}
129+
else if (c === '(') {
130+
consumeBuffer()
131+
state = 'group'
132+
}
81133
else {
82134
buffer += c
83135
}
@@ -86,6 +138,7 @@ export function parseSegment(segment: string) {
86138
case 'catchall':
87139
case 'dynamic':
88140
case 'optional':
141+
case 'group':
89142
if (buffer === '...') {
90143
buffer = ''
91144
state = 'catchall'
@@ -101,9 +154,21 @@ export function parseSegment(segment: string) {
101154

102155
state = 'initial'
103156
}
104-
else if (PARAM_CHAR_RE.test(c)) {
157+
else if (c === ')' && state === 'group') {
158+
if (!buffer)
159+
throw new Error('Empty group')
160+
else
161+
consumeBuffer()
162+
163+
state = 'initial'
164+
}
165+
else if (c && PARAM_CHAR_RE.test(c)) {
105166
buffer += c
106167
}
168+
else if (state === 'dynamic' || state === 'optional') {
169+
if (c !== '[' && c !== ']')
170+
warn?.(`'${c}' is not allowed in a dynamic route parameter and has been ignored. Consider renaming '${absolutePath}'.`)
171+
}
107172
break
108173
}
109174
i++
@@ -112,6 +177,9 @@ export function parseSegment(segment: string) {
112177
if (state === 'dynamic')
113178
throw new Error(`Unfinished param "${buffer}"`)
114179

180+
if (state === 'group')
181+
throw new Error(`Unfinished group "${buffer}"`)
182+
115183
consumeBuffer()
116184

117185
if (tokens.length === 1 && tokens[0].type === 'static' && tokens[0].value === 'index')

test/unit/converters.spec.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -83,10 +83,11 @@ describe('regexp support', () => {
8383

8484
it('toRegExp', () => {
8585
const result = Object.fromEntries(Object.entries(paths).map(([path, example]) => {
86-
const result = example.match(toRegExp(path))
86+
const regexpResult = toRegExp(path)
87+
const match = example.match(regexpResult.pattern)
8788
return [path, {
88-
regexp: toRegExp(path).toString(),
89-
result: result?.groups || result?.[0],
89+
regexp: regexpResult.pattern.toString(),
90+
result: match?.groups || match?.[0],
9091
}]
9192
}))
9293
expect(result).toMatchInlineSnapshot(`

0 commit comments

Comments
 (0)