-
-
Notifications
You must be signed in to change notification settings - Fork 58
/
tree.ts
329 lines (285 loc) · 8.28 KB
/
tree.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
import type { ResolvedOptions } from '../options'
import {
createTreeNodeValue,
TreeNodeValueOptions,
TreeRouteParam,
} from './treeNodeValue'
import type { TreeNodeValue } from './treeNodeValue'
import { trimExtension } from './utils'
import { CustomRouteBlock } from './customBlock'
import { RouteMeta } from 'vue-router'
export interface TreeNodeOptions extends ResolvedOptions {
treeNodeOptions?: TreeNodeValueOptions
}
export class TreeNode {
/**
* value of the node
*/
value: TreeNodeValue
/**
* children of the node
*/
children: Map<string, TreeNode> = new Map()
/**
* Parent node.
*/
parent?: TreeNode
/**
* Plugin options taken into account by the tree.
*/
options: TreeNodeOptions
/**
* Should this page import the page info
*/
hasDefinePage: boolean = false
constructor(options: TreeNodeOptions, filePath: string, parent?: TreeNode) {
this.options = options
this.parent = parent
this.value = createTreeNodeValue(
filePath,
parent?.value,
options.treeNodeOptions || options.pathParser
)
}
/**
* Adds a path to the tree. `path` cannot start with a `/`.
*
* @param path - path segment to insert. **It must contain the file extension** this allows to
* differentiate between folders and files.
* @param filePath - file path, defaults to path for convenience and testing
*/
insert(path: string, filePath: string = path): TreeNode {
const { tail, segment, viewName, isComponent } = splitFilePath(
path,
this.options
)
if (!this.children.has(segment)) {
this.children.set(segment, new TreeNode(this.options, segment, this))
} // TODO: else error or still override?
const child = this.children.get(segment)!
if (isComponent) {
child.value.components.set(viewName, filePath)
}
if (tail) {
return child.insert(tail, filePath)
}
return child
}
/**
* Adds a path to the tree. `path` cannot start with a `/`.
*
* @param path - path segment to insert, already parsed (e.g. users/:id)
* @param filePath - file path, defaults to path for convenience and testing
*/
insertParsedPath(path: string, filePath: string = path): TreeNode {
// TODO: allow null filePath?
const isComponent = true
const node = new TreeNode(
{
...this.options,
// force the format to raw
treeNodeOptions: {
...this.options.pathParser,
format: 'path',
},
},
path,
this
)
this.children.set(path, node)
if (isComponent) {
// TODO: allow a way to set the view name
node.value.components.set('default', filePath)
}
return node
}
setCustomRouteBlock(path: string, routeBlock: CustomRouteBlock | undefined) {
this.value.setOverride(path, routeBlock)
}
getSortedChildren() {
return Array.from(this.children.values()).sort((a, b) =>
a.path.localeCompare(b.path)
)
}
/**
* Delete and detach itself from the tree.
*/
delete() {
// TODO: rename remove to removeChild
if (!this.parent) {
throw new Error('Cannot delete the root node.')
}
this.parent.children.delete(this.value.rawSegment)
// clear link to parent
this.parent = undefined
}
/**
* Remove a route from the tree. The path shouldn't start with a `/` but it can be a nested one. e.g. `foo/bar.vue`.
* The `path` should be relative to the page folder.
*
* @param path - path segment of the file
*/
remove(path: string) {
const { tail, segment, viewName, isComponent } = splitFilePath(
path,
this.options
)
const child = this.children.get(segment)
if (!child) {
throw new Error(
`Cannot Delete "${path}". "${segment}" not found at "${this.path}".`
)
}
if (tail) {
child.remove(tail)
// if the child doesn't create any route
if (child.children.size === 0 && child.value.components.size === 0) {
this.children.delete(segment)
}
} else {
// it can only be component because we only listen for removed files, not folders
if (isComponent) {
child.value.components.delete(viewName)
}
// this is the file we wanted to remove
if (child.children.size === 0 && child.value.components.size === 0) {
this.children.delete(segment)
}
}
}
/**
* Returns the route path of the node without parent paths. If the path was overridden, it returns the override.
*/
get path() {
return (
this.value.overrides.path ??
(this.parent?.isRoot() ? '/' : '') + this.value.pathSegment
)
}
/**
* Returns the route path of the node including parent paths.
*/
get fullPath() {
return this.value.overrides.path ?? this.value.path
}
/**
* Returns the route name of the node. If the name was overridden, it returns the override.
*/
get name() {
return this.value.overrides.name || this.options.getRouteName(this)
}
/**
* Returns the meta property as an object.
*/
get metaAsObject(): Readonly<RouteMeta> {
const meta = {
...this.value.overrides.meta,
}
if (this.value.includeLoaderGuard) {
meta._loaderGuard = true
}
return meta
}
/**
* Returns the JSON string of the meta object of the node. If the meta was overridden, it returns the override. If
* there is no override, it returns an empty string.
*/
get meta() {
const overrideMeta = this.metaAsObject
return Object.keys(overrideMeta).length > 0
? JSON.stringify(overrideMeta, null, 2)
: ''
}
get params(): TreeRouteParam[] {
const params = this.value.isParam() ? [...this.value.params] : []
let node = this.parent
// add all the params from the parents
while (node) {
if (node.value.isParam()) {
params.unshift(...node.value.params)
}
node = node.parent
}
return params
}
/**
* Returns wether this tree node is the root node of the tree.
*
* @returns true if the node is the root node
*/
isRoot() {
return this.value.path === '/' && !this.value.components.size
}
toString(): string {
return `${this.value}${
// either we have multiple names
this.value.components.size > 1 ||
// or we have one name and it's not default
(this.value.components.size === 1 &&
!this.value.components.get('default'))
? ` ⎈(${Array.from(this.value.components.keys()).join(', ')})`
: ''
}${this.hasDefinePage ? ' ⚑ definePage()' : ''}`
}
}
/**
* Creates a new prefix tree. This is meant to only be the root node. It has access to extra methods that only make
* sense on the root node.
*/
export class PrefixTree extends TreeNode {
map = new Map<string, TreeNode>()
constructor(options: ResolvedOptions) {
super(options, '')
}
insert(path: string, filePath: string = path) {
const node = super.insert(path, filePath)
this.map.set(filePath, node)
return node
}
getChild(filePath: string) {
return this.map.get(filePath)
}
/**
*
* @param filePath -
*/
removeChild(filePath: string) {
if (this.map.has(filePath)) {
this.map.get(filePath)!.delete()
this.map.delete(filePath)
}
}
}
export function createPrefixTree(options: ResolvedOptions) {
return new PrefixTree(options)
}
/**
* Splits a path into by finding the first '/' and returns the tail and segment. If it has an extension, it removes it.
* If it contains a named view, it returns the view name as well (otherwise it's default).
*
* @param filePath - filePath to split
*/
function splitFilePath(filePath: string, options: ResolvedOptions) {
const slashPos = filePath.indexOf('/')
let head = slashPos < 0 ? filePath : filePath.slice(0, slashPos)
const tail = slashPos < 0 ? '' : filePath.slice(slashPos + 1)
let segment = head
// only the last segment can be a filename with an extension
if (!tail) {
segment = trimExtension(head, options.extensions)
}
let viewName = 'default'
const namedSeparatorPos = segment.indexOf('@')
if (namedSeparatorPos > 0) {
viewName = segment.slice(namedSeparatorPos + 1)
segment = segment.slice(0, namedSeparatorPos)
}
// this means we effectively trimmed an extension
const isComponent = segment !== head
return {
segment,
tail,
viewName,
isComponent,
}
}