1- import { withoutLeadingSlash , withoutTrailingSlash } from 'ufo'
2-
3- /**
4- * File system formats supported
5- * - vue-router/nuxt 3 styles
6- * - nitro style routes
7- * - Next.js style
8- * - sveltekit style
9- * - solid start style
10- */
1+ import escapeStringRegexp from 'escape-string-regexp'
2+ import { withoutLeadingSlash , withoutTrailingSlash , withTrailingSlash } from 'ufo'
113
124export interface ParsePathOptions {
135 /**
14- * By default the extension of the file is stripped. To disable this behaviour, pass
15- * an array of extensions to strip. The rest will be preserved.
6+ * File extensions to strip. If omitted, all extensions are stripped.
167 */
178 extensions ?: string [ ]
189 postfix ?: string
19- /**
20- * Warn about invalid characters in dynamic parameters
21- */
10+ /** Warn about invalid characters in dynamic parameters. */
2211 warn ?: ( message : string ) => void
2312 /**
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
13+ * Mode suffixes to detect (e.g. `['client', 'server']`).
14+ * Detected as `.mode` before the file extension.
2715 */
2816 modes ?: string [ ]
17+ /** Root paths to strip from file paths. Longest match wins. */
18+ roots ?: string [ ]
19+ }
20+
21+ export type SegmentType = 'static' | 'dynamic' | 'optional' | 'catchall' | 'group' | 'repeatable' | 'optional-repeatable'
22+
23+ export interface ParsedPathSegmentToken {
24+ type : SegmentType
25+ value : string
26+ }
27+
28+ export type ParsedPathSegment = ParsedPathSegmentToken [ ]
29+
30+ export interface ParsedPath {
31+ /** Original file path before processing. */
32+ file : string
33+ segments : ParsedPathSegment [ ]
34+ meta ?: {
35+ /** Detected modes (e.g. `['client', 'vapor']`). */
36+ modes ?: string [ ]
37+ /** Named view from `@name` suffix. */
38+ name ?: string
39+ }
2940}
3041
42+ // --- parsePath ---------------------------------------------------------------
43+
44+ const VIEW_MATCH_RE = / @ ( [ \w - ] + ) (?: \. | $ ) /
45+ const VIEW_STRIP_RE = / @ [ \w - ] + /
46+
3147export function parsePath ( filePaths : string [ ] , options : ParsePathOptions = { } ) : ParsedPath [ ] {
32- // remove file extensions (allow-listed if `options.extensions` is specified)
3348 const EXT_RE = options . extensions
3449 ? new RegExp ( `\\.(${ options . extensions . map ( ext => ext . replace ( / ^ \. / , '' ) . replace ( / [ . * + ? ^ $ { } ( ) | [ \] \\ ] / g, '\\$&' ) ) . join ( '|' ) } )$` )
3550 : / \. \w + $ /
3651
37- const parsedPaths : ParsedPath [ ] = [ ]
52+ const sortedRoots = [ ...options . roots || [ ] ] . sort ( ( a , b ) => b . length - a . length )
53+ const PREFIX_RE = sortedRoots . length > 0
54+ ? new RegExp ( `^(?:${ sortedRoots . map ( root => escapeStringRegexp ( withTrailingSlash ( root ) ) ) . join ( '|' ) } )` )
55+ : undefined
56+
57+ const supportedModes = options . modes || [ ]
58+ const results : ParsedPath [ ] = [ ]
3859
3960 for ( let filePath of filePaths ) {
61+ const originalFilePath = filePath
62+ if ( PREFIX_RE )
63+ filePath = filePath . replace ( PREFIX_RE , '' )
4064 filePath = filePath . replace ( EXT_RE , '' )
4165
42- // detect named views (@ suffix before modes/extensions)
66+ // Named views: @name suffix
4367 let namedView : string | undefined
44- const namedViewMatch = filePath . match ( / @ ( [ \w - ] + ) (?: \. | $ ) / )
45- if ( namedViewMatch ) {
46- namedView = namedViewMatch [ 1 ]
47- filePath = filePath . replace ( / @ [ \w - ] + / , '' )
68+ const viewMatch = filePath . match ( VIEW_MATCH_RE )
69+ if ( viewMatch ) {
70+ namedView = viewMatch [ 1 ]
71+ filePath = filePath . replace ( VIEW_STRIP_RE , '' )
4872 }
4973
50- // detect modes
74+ // Modes: extract from right to left (e.g. "file.client.vapor" → ['client', 'vapor'])
5175 const modes : string [ ] = [ ]
52- const supportedModes = options . modes || [ ] // no default modes
53-
54- // Extract modes from right to left to handle multiple modes like "file.client.vapor"
55- let remainingPath = filePath
56- let foundMode = true
57-
58- while ( foundMode ) {
59- foundMode = false
76+ let scanning = true
77+ while ( scanning ) {
78+ scanning = false
6079 for ( const mode of supportedModes ) {
61- const modePattern = new RegExp ( `\\.${ mode } $` )
62- if ( modePattern . test ( remainingPath ) ) {
63- modes . unshift ( mode ) // Add to front to maintain left-to-right order
64- remainingPath = remainingPath . replace ( modePattern , '' )
65- foundMode = true
80+ if ( filePath . endsWith ( `.${ mode } ` ) ) {
81+ modes . unshift ( mode )
82+ filePath = filePath . slice ( 0 , - ( mode . length + 1 ) )
83+ scanning = true
6684 break
6785 }
6886 }
6987 }
7088
71- filePath = remainingPath
72-
73- // add leading slash and remove trailing slash: test/ -> /test
74- const segments = withoutLeadingSlash ( withoutTrailingSlash ( filePath ) ) . split ( '/' )
89+ // withoutTrailingSlash('') returns '/', withoutLeadingSlash('/') returns '/'
90+ const normalized = withoutLeadingSlash ( withoutTrailingSlash ( filePath ) )
91+ const segments = normalized === '/' ? [ '' ] : normalized . split ( '/' )
7592
76- const meta : { modes ?: string [ ] , name ?: string } = { }
77- if ( modes . length > 0 )
78- meta . modes = modes
79- if ( namedView )
80- meta . name = namedView
93+ const hasModes = modes . length > 0
94+ const hasMeta = hasModes || ! ! namedView
8195
82- parsedPaths . push ( {
83- segments : segments . map ( s => parseSegment ( s , filePath , options . warn ) ) ,
84- meta : Object . keys ( meta ) . length > 0 ? meta : undefined ,
96+ results . push ( {
97+ file : originalFilePath ,
98+ segments : segments . map ( s => parseSegment ( s , originalFilePath , options . warn ) ) ,
99+ meta : hasMeta
100+ ? {
101+ ...( hasModes ? { modes } : undefined ) ,
102+ ...( namedView ? { name : namedView } : undefined ) ,
103+ }
104+ : undefined ,
85105 } )
86106 }
87107
88- return parsedPaths
108+ return results
89109}
90110
111+ // --- parseSegment ------------------------------------------------------------
91112const PARAM_CHAR_RE = / [ \w . ] /
92113
93- export type SegmentType = 'static' | 'dynamic' | 'optional' | 'catchall' | 'group' | 'repeatable' | 'optional-repeatable'
94- export interface ParsedPathSegmentToken { type : SegmentType , value : string }
95- export type ParsedPathSegment = Array < ParsedPathSegmentToken >
96- export interface ParsedPath {
97- /**
98- * The parsed segments of the file path
99- */
100- segments : ParsedPathSegment [ ]
101- /**
102- * Metadata about the parsed path including modes and named view
103- */
104- meta ?: {
105- /**
106- * The detected modes from the file path (e.g., ['client', 'vapor'])
107- */
108- modes ?: string [ ]
109- /**
110- * The named view if the file has an @name suffix
111- */
112- name ?: string
113- }
114- }
114+ export function parseSegment ( segment : string , absolutePath ?: string , warn ?: ( message : string ) => void ) : ParsedPathSegmentToken [ ] {
115+ if ( segment === '' )
116+ return [ { type : 'static' , value : '' } ]
115117
116- export function parseSegment ( segment : string , absolutePath ?: string , warn ?: ( message : string ) => void ) {
117- type SegmentParserState = 'initial' | SegmentType
118- let state : SegmentParserState = 'initial'
118+ type State = 'initial' | SegmentType
119+ let state : State = 'initial'
119120 let i = 0
120-
121121 let buffer = ''
122122 const tokens : ParsedPathSegmentToken [ ] = [ ]
123123
124- function consumeBuffer ( state : Exclude < SegmentType , 'initial' > ) {
125- tokens . push ( {
126- type : state ,
127- value : buffer ,
128- } )
129-
124+ function flush ( type : SegmentType ) {
125+ tokens . push ( { type, value : buffer } )
130126 buffer = ''
131127 }
132128
@@ -150,11 +146,11 @@ export function parseSegment(segment: string, absolutePath?: string, warn?: (mes
150146
151147 case 'static' :
152148 if ( c === '[' ) {
153- consumeBuffer ( state )
149+ flush ( state )
154150 state = 'dynamic'
155151 }
156152 else if ( c === '(' ) {
157- consumeBuffer ( state )
153+ flush ( state )
158154 state = 'group'
159155 }
160156 else {
@@ -177,45 +173,30 @@ export function parseSegment(segment: string, absolutePath?: string, warn?: (mes
177173 if ( ! buffer )
178174 throw new Error ( 'Empty param' )
179175
180- // Check for + modifier after closing bracket
181176 if ( segment [ i + 1 ] === '+' ) {
182- if ( state === 'optional' ) {
183- // [[param]]+ -> optional-repeatable
184- tokens . push ( {
185- type : 'optional-repeatable' ,
186- value : buffer ,
187- } )
188- }
189- else {
190- // [param]+ -> repeatable
191- tokens . push ( {
192- type : 'repeatable' ,
193- value : buffer ,
194- } )
195- }
177+ tokens . push ( {
178+ type : state === 'optional' ? 'optional-repeatable' : 'repeatable' ,
179+ value : buffer ,
180+ } )
196181 buffer = ''
197- i ++ // skip the + character
182+ i ++
198183 }
199184 else {
200- consumeBuffer ( state )
185+ flush ( state )
201186 }
202-
203187 state = 'initial'
204188 }
205189 else if ( c === ')' && state === 'group' ) {
206190 if ( ! buffer )
207191 throw new Error ( 'Empty group' )
208- else
209- consumeBuffer ( state )
210-
192+ flush ( state )
211193 state = 'initial'
212194 }
213195 else if ( c && PARAM_CHAR_RE . test ( c ) ) {
214196 buffer += c
215197 }
216- else if ( state === 'dynamic' || state === 'optional' ) {
217- if ( c !== '[' && c !== ']' )
218- warn ?.( `'${ c } ' is not allowed in a dynamic route parameter and has been ignored. Consider renaming '${ absolutePath } '.` )
198+ else if ( ( state === 'dynamic' || state === 'optional' ) && c !== '[' && c !== ']' ) {
199+ warn ?.( `'${ c } ' is not allowed in a dynamic route parameter and has been ignored. Consider renaming '${ absolutePath } '.` )
219200 }
220201 break
221202 }
@@ -224,14 +205,12 @@ export function parseSegment(segment: string, absolutePath?: string, warn?: (mes
224205
225206 if ( state === 'dynamic' )
226207 throw new Error ( `Unfinished param "${ buffer } "` )
227-
228208 if ( state === 'group' )
229209 throw new Error ( `Unfinished group "${ buffer } "` )
210+ if ( state !== 'initial' && buffer )
211+ flush ( state )
230212
231- if ( state !== 'initial' && buffer ) {
232- consumeBuffer ( state )
233- }
234-
213+ // Normalize index → empty static
235214 if ( tokens . length === 1 && tokens [ 0 ] . type === 'static' && tokens [ 0 ] . value === 'index' )
236215 tokens [ 0 ] . value = ''
237216
0 commit comments