Skip to content

Commit 8557359

Browse files
authored
feat!: support for named views/repeatable segments + fix regexp output (#67)
1 parent db86fd6 commit 8557359

File tree

5 files changed

+466
-74
lines changed

5 files changed

+466
-74
lines changed

README.md

Lines changed: 62 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ This library is a work in progress and in active development.
1515

1616
- [ ] generic route parsing function with options to cover major filesystem routing patterns
1717
- [x] [Nuxt](https://github.com/nuxt/nuxt)
18-
- [ ] [unplugin-vue-router](https://github.com/posva/unplugin-vue-router)
18+
- [x] [unplugin-vue-router](https://github.com/posva/unplugin-vue-router) (does not include dot-syntax nesting support)
1919
- [ ] export capability for framework routers
2020
- [x] RegExp patterns
2121
- [x] [`vue-router`](https://router.vuejs.org/) routes
@@ -50,7 +50,7 @@ console.log(result.segments)
5050
// [{ type: 'dynamic', value: 'id' }],
5151
// [{ type: 'static', value: 'profile' }]
5252
// ]
53-
console.log(result.modes) // undefined (no modes detected)
53+
console.log(result.meta) // undefined (no metadata detected)
5454
```
5555

5656
### Mode Detection
@@ -63,17 +63,43 @@ const result = parsePath('app.server.vue', {
6363
modes: ['server', 'client']
6464
})
6565

66-
console.log(result.modes) // ['server']
66+
console.log(result.meta?.modes) // ['server']
6767
console.log(result.segments) // [[{ type: 'static', value: 'app' }]]
6868

6969
// Multiple modes
7070
const result2 = parsePath('api.server.edge.js', {
7171
modes: ['server', 'client', 'edge']
7272
})
73-
console.log(result2.modes) // ['server', 'edge']
73+
console.log(result2.meta?.modes) // ['server', 'edge']
7474
console.log(result2.segments) // [[{ type: 'static', value: 'api' }]]
7575
```
7676
77+
### Named Views
78+
79+
```js
80+
import { parsePath } from 'unrouting'
81+
82+
// Named views with @ suffix (for Vue Router named views)
83+
const result = parsePath('dashboard@sidebar.vue')
84+
console.log(result.meta?.name) // 'sidebar'
85+
console.log(result.segments) // [[{ type: 'static', value: 'dashboard' }]]
86+
87+
// Named views with modes
88+
const result2 = parsePath('admin@main.client.vue', {
89+
modes: ['client', 'server']
90+
})
91+
console.log(result2.meta) // { name: 'main', modes: ['client'] }
92+
93+
// Nested named views
94+
const result3 = parsePath('users/[id]@profile.vue')
95+
console.log(result3.meta?.name) // 'profile'
96+
console.log(result3.segments)
97+
// [
98+
// [{ type: 'static', value: 'users' }],
99+
// [{ type: 'dynamic', value: 'id' }]
100+
// ]
101+
```
102+
77103
### Convert to Router Formats
78104
79105
```js
@@ -99,20 +125,39 @@ console.log(regexpRoute.keys) // ['id', 'slug']
99125
### Advanced Examples
100126
101127
```js
102-
import { parsePath } from 'unrouting'
128+
import { parsePath, toRegExp, toVueRouter4 } from 'unrouting'
103129

104-
// Group segments (ignored in final path)
105-
const result = parsePath('(admin)/(dashboard)/users/[id].vue')
106-
console.log(result.segments)
107-
// Groups are parsed but skipped in path generation
130+
// Repeatable parameters ([slug]+.vue -> one or more segments)
131+
const repeatable = parsePath('posts/[slug]+.vue')
132+
console.log(toVueRouter4(repeatable.segments).path) // '/posts/:slug+'
133+
134+
// Optional repeatable parameters ([[slug]]+.vue -> zero or more segments)
135+
const optionalRepeatable = parsePath('articles/[[slug]]+.vue')
136+
console.log(toVueRouter4(optionalRepeatable.segments).path) // '/articles/:slug*'
137+
138+
// Group segments (ignored in final path, useful for organization)
139+
const grouped = parsePath('(admin)/(dashboard)/users/[id].vue')
140+
console.log(toVueRouter4(grouped.segments).path) // '/users/:id()'
141+
// Groups are parsed but excluded from path generation
108142

109-
// Catchall routes
143+
// Catchall routes ([...slug].vue -> captures remaining path)
110144
const catchall = parsePath('docs/[...slug].vue')
111-
// catchall.segments converts to /docs/:slug(.*)*
145+
console.log(toVueRouter4(catchall.segments).path) // '/docs/:slug(.*)*'
112146

113-
// Optional parameters
147+
// Optional parameters ([[param]].vue -> parameter is optional)
114148
const optional = parsePath('products/[[category]]/[[id]].vue')
115-
// optional.segments converts to /products/:category?/:id?
149+
console.log(toVueRouter4(optional.segments).path) // '/products/:category?/:id?'
150+
151+
// Complex mixed patterns
152+
const complex = parsePath('shop/[category]/product-[id]-[[variant]].vue')
153+
console.log(toVueRouter4(complex.segments).path)
154+
// '/shop/:category()/product-:id()-:variant?'
155+
156+
// Proper regex matching with anchoring (fixes partial match issues)
157+
const pattern = toRegExp('[slug].vue')
158+
console.log(pattern.pattern) // /^\/(?<slug>[^/]+)\/?$/
159+
console.log('/file'.match(pattern.pattern)) // ✅ matches
160+
console.log('/test/thing'.match(pattern.pattern)) // ❌ null (properly rejected)
116161
```
117162
118163
## API
@@ -132,7 +177,10 @@ Parse a file path into route segments with mode detection.
132177
```ts
133178
interface ParsedPath {
134179
segments: ParsedPathSegment[]
135-
modes?: string[]
180+
meta?: {
181+
modes?: string[] // Detected mode suffixes (e.g., ['client', 'server'])
182+
name?: string // Named view from @name suffix
183+
}
136184
}
137185
```
138186

src/converters.ts

Lines changed: 46 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,12 @@ export function toRou3(filePath: string | ParsedPathSegment[]) {
3737
if (token.type === 'optional')
3838
throw new TypeError('[unrouting] `toRou3` does not support optional parameters')
3939

40+
if (token.type === 'repeatable')
41+
throw new TypeError('[unrouting] `toRou3` does not support repeatable parameters')
42+
43+
if (token.type === 'optional-repeatable')
44+
throw new TypeError('[unrouting] `toRou3` does not support optional repeatable parameters')
45+
4046
if (token.type === 'catchall')
4147
rou3Segment += token.value ? `**:${token.value}` : '**'
4248
}
@@ -79,6 +85,14 @@ export function toVueRouter4(filePath: string | ParsedPathSegment[]) {
7985
pathSegment += `:${token.value}?`
8086
continue
8187
}
88+
if (token.type === 'repeatable') {
89+
pathSegment += `:${token.value}+`
90+
continue
91+
}
92+
if (token.type === 'optional-repeatable') {
93+
pathSegment += `:${token.value}*`
94+
continue
95+
}
8296
if (token.type === 'catchall') {
8397
pathSegment += hasSucceedingSegment ? `:${token.value}([^/]*)*` : `:${token.value}(.*)*`
8498
continue
@@ -102,9 +116,11 @@ export function toRegExp(filePath: string | ParsedPathSegment[]) {
102116
const segments = typeof filePath === 'string' ? parsePath(filePath).segments : filePath
103117

104118
const keys: string[] = []
105-
let sourceRE = '\\/'
119+
let sourceRE = '^'
120+
121+
for (let i = 0; i < segments.length; i++) {
122+
const segment = segments[i]
106123

107-
for (const segment of segments) {
108124
if (segment.every(token => token.type === 'group'))
109125
continue
110126

@@ -125,24 +141,44 @@ export function toRegExp(filePath: string | ParsedPathSegment[]) {
125141
reSegment += `(?<${key}>[^/]*)`
126142
}
127143

144+
if (token.type === 'repeatable') {
145+
const key = sanitizeCaptureGroup(token.value)
146+
keys.push(key)
147+
reSegment += `(?<${key}>[^/]+(?:/[^/]+)*)`
148+
}
149+
150+
if (token.type === 'optional-repeatable') {
151+
const key = sanitizeCaptureGroup(token.value)
152+
keys.push(key)
153+
reSegment += `(?<${key}>[^/]*(?:/[^/]+)*)`
154+
}
155+
128156
if (token.type === 'catchall') {
129157
const key = sanitizeCaptureGroup(token.value)
130158
keys.push(key)
131159
reSegment += `(?<${key}>.*)`
132160
}
133161
}
134162

135-
if (segment.every(token => token.type === 'optional' || token.type === 'catchall' || token.type === 'group')) {
136-
sourceRE += `(?:${reSegment}\\/?)`
137-
}
138-
else if (reSegment) {
139-
// If a segment has value '' we skip adding a trailing slash
140-
sourceRE += `${reSegment}\\/`
163+
// Check if the entire segment is optional (contains only optional, catchall, or optional-repeatable tokens, or groups)
164+
const isOptionalSegment = segment.every(token =>
165+
token.type === 'optional'
166+
|| token.type === 'catchall'
167+
|| token.type === 'group'
168+
|| token.type === 'optional-repeatable',
169+
)
170+
171+
// Add slash and segment content
172+
if (reSegment) {
173+
if (isOptionalSegment)
174+
sourceRE += `(?:\\/${reSegment})?`
175+
else
176+
sourceRE += `\\/${reSegment}`
141177
}
142178
}
143179

144-
// make final slash optional
145-
sourceRE += '?'
180+
// Add optional trailing slash and end anchor
181+
sourceRE += '\\/?$'
146182

147183
return {
148184
pattern: new RegExp(sourceRE),

src/parse.ts

Lines changed: 52 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -30,9 +30,19 @@ export interface ParsePathOptions {
3030

3131
export function parsePath(filePath: string, options: ParsePathOptions = {}): ParsedPath {
3232
// remove file extensions (allow-listed if `options.extensions` is specified)
33-
const EXT_RE = options.extensions ? new RegExp(`\\.(${options.extensions.join('|')})$`) : /\.\w+$/
33+
const EXT_RE = options.extensions
34+
? new RegExp(`\\.(${options.extensions.map(ext => ext.replace(/^\./, '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|')})$`)
35+
: /\.\w+$/
3436
filePath = filePath.replace(EXT_RE, '')
3537

38+
// detect named views (@ suffix before modes/extensions)
39+
let namedView: string | undefined
40+
const namedViewMatch = filePath.match(/@([\w-]+)(?:\.|$)/)
41+
if (namedViewMatch) {
42+
namedView = namedViewMatch[1]
43+
filePath = filePath.replace(/@[\w-]+/, '')
44+
}
45+
3646
// detect modes
3747
const modes: string[] = []
3848
const supportedModes = options.modes || [] // no default modes
@@ -59,15 +69,21 @@ export function parsePath(filePath: string, options: ParsePathOptions = {}): Par
5969
// add leading slash and remove trailing slash: test/ -> /test
6070
const segments = withoutLeadingSlash(withoutTrailingSlash(filePath)).split('/')
6171

72+
const meta: { modes?: string[], name?: string } = {}
73+
if (modes.length > 0)
74+
meta.modes = modes
75+
if (namedView)
76+
meta.name = namedView
77+
6278
return {
6379
segments: segments.map(s => parseSegment(s, filePath, options.warn)),
64-
modes: modes.length > 0 ? modes : undefined,
80+
meta: Object.keys(meta).length > 0 ? meta : undefined,
6581
}
6682
}
6783

6884
const PARAM_CHAR_RE = /[\w.]/
6985

70-
export type SegmentType = 'static' | 'dynamic' | 'optional' | 'catchall' | 'group'
86+
export type SegmentType = 'static' | 'dynamic' | 'optional' | 'catchall' | 'group' | 'repeatable' | 'optional-repeatable'
7187
export interface ParsedPathSegmentToken { type: SegmentType, value: string }
7288
export type ParsedPathSegment = Array<ParsedPathSegmentToken>
7389
export interface ParsedPath {
@@ -76,9 +92,18 @@ export interface ParsedPath {
7692
*/
7793
segments: ParsedPathSegment[]
7894
/**
79-
* The detected modes from the file path (e.g., ['client', 'vapor'])
95+
* Metadata about the parsed path including modes and named view
8096
*/
81-
modes?: string[]
97+
meta?: {
98+
/**
99+
* The detected modes from the file path (e.g., ['client', 'vapor'])
100+
*/
101+
modes?: string[]
102+
/**
103+
* The named view if the file has an @name suffix
104+
*/
105+
name?: string
106+
}
82107
}
83108

84109
export function parseSegment(segment: string, absolutePath?: string, warn?: (message: string) => void) {
@@ -149,8 +174,29 @@ export function parseSegment(segment: string, absolutePath?: string, warn?: (mes
149174
if (c === ']' && (state !== 'optional' || segment[i - 1] === ']')) {
150175
if (!buffer)
151176
throw new Error('Empty param')
152-
else
177+
178+
// Check for + modifier after closing bracket
179+
if (segment[i + 1] === '+') {
180+
if (state === 'optional') {
181+
// [[param]]+ -> optional-repeatable
182+
tokens.push({
183+
type: 'optional-repeatable',
184+
value: buffer,
185+
})
186+
}
187+
else {
188+
// [param]+ -> repeatable
189+
tokens.push({
190+
type: 'repeatable',
191+
value: buffer,
192+
})
193+
}
194+
buffer = ''
195+
i++ // skip the + character
196+
}
197+
else {
153198
consumeBuffer()
199+
}
154200

155201
state = 'initial'
156202
}

0 commit comments

Comments
 (0)