-
-
Notifications
You must be signed in to change notification settings - Fork 91
/
Copy pathtree.ts
333 lines (292 loc) · 8.78 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
330
331
332
333
import { type ResolvedOptions } from '../options'
import {
createTreeNodeValue,
TreeNodeValueOptions,
TreeRouteParam,
} from './treeNodeValue'
import type { TreeNodeValue } from './treeNodeValue'
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 | undefined
/**
* Plugin options taken into account by the tree.
*/
options: TreeNodeOptions
// FIXME: refactor this code. It currently helps to keep track if a page has at least one component with `definePage()` but it doesn't tell which. It should keep track of which one while still caching the result per file.
/**
* Should this page import the page info
*/
hasDefinePage: boolean = false
/**
* Creates a new tree node.
*
* @param options - TreeNodeOptions shared by all nodes
* @param pathSegment - path segment of this node e.g. `users` or `:id`
* @param parent
*/
constructor(
options: TreeNodeOptions,
pathSegment: string,
parent?: TreeNode
) {
this.options = options
this.parent = parent
this.value = createTreeNodeValue(
pathSegment,
parent?.value,
options.treeNodeOptions || options.pathParser
)
}
/**
* Adds a path to the tree. `path` cannot start with a `/`.
*
* @param path - path segment to insert. **It shouldn't contain the file extension**
* @param filePath - file path, must be a file (not a folder)
*/
insert(path: string, filePath: string): TreeNode {
const { tail, segment, viewName } = splitFilePath(path)
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)!
// we reached the end of the filePath, therefore it's a component
if (!tail) {
child.value.components.set(viewName, filePath)
} else {
return child.insert(tail, filePath)
}
return child
}
/**
* Adds a path that has already been parsed to the tree. `path` cannot start with a `/`. This method is similar to
* `insert` but the path argument should be already parsed. e.g. `users/:id` for a file named `users/[id].vue`.
*
* @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
}
/**
* Saves a custom route block for a specific file path. The file path is used as a key. Some special file paths will
* have a lower or higher priority.
*
* @param filePath - file path where the custom block is located
* @param routeBlock - custom block to set
*/
setCustomRouteBlock(
filePath: string,
routeBlock: CustomRouteBlock | undefined
) {
this.value.setOverride(filePath, routeBlock)
}
getSortedChildren() {
return Array.from(this.children.values()).sort((a, b) =>
a.path.localeCompare(b.path)
)
}
/**
* Delete and detach itself from the tree.
*/
delete() {
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`.
* The `path` should be relative to the page folder.
*
* @param path - path segment of the file
*/
remove(path: string) {
// TODO: rename remove to removeChild
const { tail, segment, viewName } = splitFilePath(path)
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
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> {
return {
...this.value.overrides.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.parent && 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, '')
}
override insert(path: string, filePath: string) {
const node = super.insert(path, filePath)
this.map.set(filePath, node)
return node
}
/**
* Returns the tree node of the given file path.
*
* @param filePath - file path of the tree node to get
*/
getChild(filePath: string) {
return this.map.get(filePath)
}
/**
* Removes the tree node of the given file path.
*
* @param filePath - file path of the tree node to remove
*/
removeChild(filePath: string) {
if (this.map.has(filePath)) {
this.map.get(filePath)!.delete()
this.map.delete(filePath)
}
}
}
/**
* 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) {
const slashPos = filePath.indexOf('/')
let head = slashPos < 0 ? filePath : filePath.slice(0, slashPos)
const tail = slashPos < 0 ? '' : filePath.slice(slashPos + 1)
let segment = head
let viewName = 'default'
const namedSeparatorPos = segment.indexOf('@')
if (namedSeparatorPos > 0) {
viewName = segment.slice(namedSeparatorPos + 1)
segment = segment.slice(0, namedSeparatorPos)
}
return {
segment,
tail,
viewName,
}
}