Skip to content

Commit 01b2e62

Browse files
authored
feat!: rewrite to use route tree (#100)
1 parent 7c98ba9 commit 01b2e62

File tree

9 files changed

+2254
-604
lines changed

9 files changed

+2254
-604
lines changed

README.md

Lines changed: 355 additions & 147 deletions
Large diffs are not rendered by default.

src/converters.ts

Lines changed: 398 additions & 153 deletions
Large diffs are not rendered by default.

src/index.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
1-
export { toRegExp, toRou3, toVueRouter4 } from './converters'
1+
export type { RegExpRoute, Rou3Route, ToVueRouterSegmentOptions, VueRoute, VueRouterEmitOptions } from './converters'
2+
export { toRegExp, toRou3, toVueRouter4, toVueRouterPath, toVueRouterSegment } from './converters'
23

34
export type { ParsedPath, ParsedPathSegment, ParsedPathSegmentToken, ParsePathOptions, SegmentType } from './parse'
4-
export { parsePath } from './parse'
5+
export { parsePath, parseSegment } from './parse'
6+
7+
export type { BuildTreeOptions, InputFile, RouteNode, RouteNodeFile, RouteTree } from './tree'
8+
export { addFile, buildTree, isPageNode, removeFile, walkTree } from './tree'

src/parse.ts

Lines changed: 94 additions & 115 deletions
Original file line numberDiff line numberDiff line change
@@ -1,132 +1,128 @@
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

124
export 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+
3147
export 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 ------------------------------------------------------------
91112
const 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

Comments
 (0)