path-tree-matcher 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +48 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/path-tree.d.ts +36 -0
- package/dist/path-tree.js +271 -0
- package/package.json +30 -0
package/README.md
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# path-tree-matcher
|
|
2
|
+
|
|
3
|
+
A tiny TypeScript path tree matcher for static routes, optional groups, named parameters, and wildcards.
|
|
4
|
+
|
|
5
|
+
## Quick usage
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
import { PathTree } from "path-tree-matcher"
|
|
9
|
+
|
|
10
|
+
const tree = new PathTree()
|
|
11
|
+
|
|
12
|
+
// register patterns
|
|
13
|
+
tree.setPattern("/a/b{/d}/hello")
|
|
14
|
+
tree.setPattern("/a/b/:name/hello")
|
|
15
|
+
tree.setPattern("/a/b/*all")
|
|
16
|
+
|
|
17
|
+
// match a pathname
|
|
18
|
+
const matches = tree.match("/a/b/d/hello")
|
|
19
|
+
for (const { pattern, params } of matches) {
|
|
20
|
+
console.log(pattern)
|
|
21
|
+
console.log(params)
|
|
22
|
+
}
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## What these methods do
|
|
26
|
+
|
|
27
|
+
- `setPattern(pattern, mapper)` registers a route pattern into the tree
|
|
28
|
+
- `match(pathname)` returns all matching route results with parameter values
|
|
29
|
+
|
|
30
|
+
### Example behaviors
|
|
31
|
+
|
|
32
|
+
- `/a/b{/d}/hello` matches both `/a/b/hello` and `/a/b/d/hello`
|
|
33
|
+
- `:name` captures a single named segment
|
|
34
|
+
- `*all` captures a wildcard path segment sequence
|
|
35
|
+
|
|
36
|
+
### Path syntax
|
|
37
|
+
|
|
38
|
+
- static text: `/users`
|
|
39
|
+
- named parameter: `/:id`
|
|
40
|
+
- wildcard: `/*rest`
|
|
41
|
+
- optional group: `{/segment}`
|
|
42
|
+
|
|
43
|
+
### Syntax notes
|
|
44
|
+
- Invalid consecutive params: `/users/:id:action`
|
|
45
|
+
- unable to split path into two variables: `id`, and `action`
|
|
46
|
+
- Repeated variable name: `/users/:id/follow/:id`
|
|
47
|
+
- params[":id"] === [value0, value1]
|
|
48
|
+
- Pedantic mode: `/users/action` !== `users/action` !== `users/action/` !== `/users/action/`
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { PathTree } from './path-tree.ts';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { PathTree } from './path-tree.js';
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export type Token = {
|
|
2
|
+
token: string;
|
|
3
|
+
type: "param" | "wildcard";
|
|
4
|
+
} | {
|
|
5
|
+
token: string;
|
|
6
|
+
type: "group";
|
|
7
|
+
groups: Token[];
|
|
8
|
+
} | {
|
|
9
|
+
token: string;
|
|
10
|
+
type: "literal";
|
|
11
|
+
};
|
|
12
|
+
export type TokenType = Token["type"];
|
|
13
|
+
export type MatchResult = {
|
|
14
|
+
pattern: string;
|
|
15
|
+
params: Record<string, string[]>;
|
|
16
|
+
};
|
|
17
|
+
export type PathNode = {
|
|
18
|
+
literals: Record<string, PathNode>;
|
|
19
|
+
params: Record<string, PathNode>;
|
|
20
|
+
wildcards: Record<string, PathNode>;
|
|
21
|
+
groups: Record<string, PathNode>;
|
|
22
|
+
expandedFrom: string[];
|
|
23
|
+
};
|
|
24
|
+
export declare class PathTree {
|
|
25
|
+
root: PathNode;
|
|
26
|
+
setPattern(path: string, node?: PathNode, expandedFrom?: string): void;
|
|
27
|
+
collectLiterals(node: PathNode): {
|
|
28
|
+
startToken: string;
|
|
29
|
+
lastNode: PathNode;
|
|
30
|
+
finished?: true;
|
|
31
|
+
}[];
|
|
32
|
+
matchRecursive(path: string, root?: PathNode, param?: Record<string, string[]>): Generator<MatchResult>;
|
|
33
|
+
match(path: string): MatchResult[];
|
|
34
|
+
parsePathIntoSegments(pathname: string): Generator<string>;
|
|
35
|
+
parsePathTree(pathname: string, parent?: string): Token[];
|
|
36
|
+
}
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
function newNode() {
|
|
2
|
+
return {
|
|
3
|
+
literals: {},
|
|
4
|
+
params: {},
|
|
5
|
+
wildcards: {},
|
|
6
|
+
groups: {},
|
|
7
|
+
expandedFrom: [],
|
|
8
|
+
};
|
|
9
|
+
}
|
|
10
|
+
function lastIndexOf(str, search) {
|
|
11
|
+
let lastIndex = -1;
|
|
12
|
+
let index = str.indexOf(search);
|
|
13
|
+
while (index !== -1) {
|
|
14
|
+
lastIndex = index;
|
|
15
|
+
index = str.indexOf(search, index + 1);
|
|
16
|
+
}
|
|
17
|
+
return lastIndex;
|
|
18
|
+
}
|
|
19
|
+
export class PathTree {
|
|
20
|
+
root = newNode();
|
|
21
|
+
setPattern(path, node = this.root, expandedFrom) {
|
|
22
|
+
if (!path) {
|
|
23
|
+
throw new Error("Path cannot be empty");
|
|
24
|
+
}
|
|
25
|
+
const tokens = this.parsePathTree(path);
|
|
26
|
+
let currentNode = node;
|
|
27
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
28
|
+
const token = tokens[i];
|
|
29
|
+
currentNode[`${token.type}s`][token.token] ??= newNode();
|
|
30
|
+
switch (token.type) {
|
|
31
|
+
case "literal": {
|
|
32
|
+
currentNode = currentNode.literals[token.token];
|
|
33
|
+
break;
|
|
34
|
+
}
|
|
35
|
+
case "param": {
|
|
36
|
+
if (token.token.length < 2) {
|
|
37
|
+
throw new Error(`Parameter token ${token.token} is too short in path ${path}`);
|
|
38
|
+
}
|
|
39
|
+
if (tokens[i - 1]?.type === "param" || tokens[i - 1]?.type === "wildcard") {
|
|
40
|
+
throw new Error(`Unexpected consecutive parameter tokens : ${token.token} in path ${path}`);
|
|
41
|
+
}
|
|
42
|
+
currentNode = currentNode.params[token.token];
|
|
43
|
+
break;
|
|
44
|
+
}
|
|
45
|
+
case "wildcard": {
|
|
46
|
+
if (token.token.length < 2) {
|
|
47
|
+
throw new Error(`Parameter token ${token.token} is too short in path ${path}`);
|
|
48
|
+
}
|
|
49
|
+
if (tokens[i - 1]?.type === "param" || tokens[i - 1]?.type === "wildcard") {
|
|
50
|
+
throw new Error(`Unexpected consecutive parameter tokens : ${token.token} in path ${path}`);
|
|
51
|
+
}
|
|
52
|
+
currentNode = currentNode.wildcards[token.token];
|
|
53
|
+
break;
|
|
54
|
+
}
|
|
55
|
+
case "group": {
|
|
56
|
+
const joined = [token.token.slice(1, -1), ...tokens.slice(i + 1).map(e => e.token)].join('');
|
|
57
|
+
this.setPattern(joined, currentNode.groups[token.token], expandedFrom ?? path);
|
|
58
|
+
break;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
currentNode.expandedFrom.push(expandedFrom ?? path);
|
|
63
|
+
}
|
|
64
|
+
collectLiterals(node) {
|
|
65
|
+
let toIterate = Object.entries(node.literals).map(([token, node]) => ({
|
|
66
|
+
startToken: token,
|
|
67
|
+
lastNode: node,
|
|
68
|
+
}));
|
|
69
|
+
let iterateNext;
|
|
70
|
+
const finished = [];
|
|
71
|
+
while (toIterate.length) {
|
|
72
|
+
iterateNext = [];
|
|
73
|
+
for (const c of toIterate) {
|
|
74
|
+
for (const [token, node] of Object.entries(c.lastNode.literals)) {
|
|
75
|
+
iterateNext.push({
|
|
76
|
+
startToken: c.startToken + token,
|
|
77
|
+
lastNode: node
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
if (Object.entries(c.lastNode.params).length
|
|
81
|
+
|| Object.entries(c.lastNode.wildcards).length
|
|
82
|
+
|| Object.entries(c.lastNode.groups).length
|
|
83
|
+
|| c.lastNode.expandedFrom.length) {
|
|
84
|
+
finished.push({ ...c });
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
toIterate = iterateNext;
|
|
88
|
+
}
|
|
89
|
+
return finished;
|
|
90
|
+
}
|
|
91
|
+
*matchRecursive(path, root = this.root, param = {}) {
|
|
92
|
+
for (const [token, node] of Object.entries(root.literals)) {
|
|
93
|
+
if (path === token) {
|
|
94
|
+
yield* node.expandedFrom.map(p => {
|
|
95
|
+
return { pattern: p, params: { ...param } };
|
|
96
|
+
});
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
if (path.startsWith(token)) {
|
|
100
|
+
yield* this.matchRecursive(path.slice(token.length), node, { ...param });
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
for (const [token, node] of Object.entries(root.params)) {
|
|
104
|
+
// must only match literal of '/' next
|
|
105
|
+
const indexOfSlash = path.indexOf("/");
|
|
106
|
+
const index = indexOfSlash === -1 ? path.length : indexOfSlash;
|
|
107
|
+
const paramValue = path.slice(0, index);
|
|
108
|
+
const newParam = {
|
|
109
|
+
...param,
|
|
110
|
+
[token]: [...(param[token] ?? []), paramValue]
|
|
111
|
+
};
|
|
112
|
+
if (path.slice(index)) {
|
|
113
|
+
yield* this.matchRecursive(path.slice(index), node, newParam);
|
|
114
|
+
}
|
|
115
|
+
else {
|
|
116
|
+
yield* node.expandedFrom.map(p => {
|
|
117
|
+
return { pattern: p, params: newParam };
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
for (const [token, node] of Object.entries(root.wildcards)) {
|
|
122
|
+
const nextLiterals = this.collectLiterals(node);
|
|
123
|
+
if (!nextLiterals.length) {
|
|
124
|
+
const newParam = {
|
|
125
|
+
...param,
|
|
126
|
+
[token]: [...(param[token] ?? []), path]
|
|
127
|
+
};
|
|
128
|
+
yield* node.expandedFrom.map(p => {
|
|
129
|
+
return { pattern: p, params: newParam };
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
else {
|
|
133
|
+
for (const { startToken, lastNode } of nextLiterals) {
|
|
134
|
+
const index = path.indexOf(startToken);
|
|
135
|
+
if (index === -1)
|
|
136
|
+
continue;
|
|
137
|
+
const paramValue = path.slice(0, index);
|
|
138
|
+
const newParam = {
|
|
139
|
+
...param,
|
|
140
|
+
[token]: [...(param[token] ?? []), paramValue]
|
|
141
|
+
};
|
|
142
|
+
const nextPath = path.slice(index + startToken.length);
|
|
143
|
+
if (nextPath) {
|
|
144
|
+
yield* this.matchRecursive(nextPath, lastNode, newParam);
|
|
145
|
+
}
|
|
146
|
+
else {
|
|
147
|
+
yield* lastNode.expandedFrom.map(p => {
|
|
148
|
+
return { pattern: p, params: newParam };
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
// Also check if the wildcard node itself is a terminal (has expandedFrom)
|
|
153
|
+
// This handles cases where the wildcard matches everything
|
|
154
|
+
if (node.expandedFrom.length) {
|
|
155
|
+
const newParam = {
|
|
156
|
+
...param,
|
|
157
|
+
[token]: [...(param[token] ?? []), path]
|
|
158
|
+
};
|
|
159
|
+
yield* node.expandedFrom.map(p => {
|
|
160
|
+
return { pattern: p, params: newParam };
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
for (const [_, node] of Object.entries(root.groups)) {
|
|
166
|
+
yield* this.matchRecursive(path, node, { ...param });
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
match(path) {
|
|
170
|
+
return Array.from(this.matchRecursive(path));
|
|
171
|
+
}
|
|
172
|
+
*parsePathIntoSegments(pathname) {
|
|
173
|
+
let lower = 0;
|
|
174
|
+
let upper = 0;
|
|
175
|
+
let depth = 0;
|
|
176
|
+
const delimiter = '/';
|
|
177
|
+
const groupStart = '{';
|
|
178
|
+
const groupEnd = '}';
|
|
179
|
+
const paramDelimiter = ':';
|
|
180
|
+
const wildcardDelimiter = '*';
|
|
181
|
+
while (upper < pathname.length) {
|
|
182
|
+
if (pathname[upper] === groupStart) {
|
|
183
|
+
if (depth === 0) {
|
|
184
|
+
if (pathname.slice(lower, upper)) {
|
|
185
|
+
yield pathname.slice(lower, upper);
|
|
186
|
+
}
|
|
187
|
+
lower = upper;
|
|
188
|
+
}
|
|
189
|
+
depth++;
|
|
190
|
+
}
|
|
191
|
+
else if (pathname[upper] === groupEnd) {
|
|
192
|
+
depth--;
|
|
193
|
+
}
|
|
194
|
+
else if (depth === 0) {
|
|
195
|
+
if (pathname[upper - 1] === groupEnd) {
|
|
196
|
+
yield pathname.slice(lower, upper);
|
|
197
|
+
lower = upper;
|
|
198
|
+
}
|
|
199
|
+
if (pathname[upper] === delimiter) {
|
|
200
|
+
if (pathname.slice(lower, upper)) {
|
|
201
|
+
yield pathname.slice(lower, upper);
|
|
202
|
+
lower = upper;
|
|
203
|
+
}
|
|
204
|
+
yield pathname.slice(lower, upper + 1);
|
|
205
|
+
lower = upper + 1;
|
|
206
|
+
}
|
|
207
|
+
else if (pathname[upper] === paramDelimiter || pathname[upper] === wildcardDelimiter) {
|
|
208
|
+
if (pathname.slice(lower, upper)) {
|
|
209
|
+
yield pathname.slice(lower, upper);
|
|
210
|
+
lower = upper;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
upper++;
|
|
215
|
+
}
|
|
216
|
+
if (lower < upper) {
|
|
217
|
+
yield pathname.slice(lower, upper);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
parsePathTree(pathname, parent) {
|
|
221
|
+
const splitted = this.parsePathIntoSegments(pathname);
|
|
222
|
+
const matched = [];
|
|
223
|
+
for (const split of splitted) {
|
|
224
|
+
if (split.startsWith("{")) {
|
|
225
|
+
if (!split.endsWith("}")) {
|
|
226
|
+
throw new Error(`Unterminated group of ${split} in path ${parent ?? pathname}`);
|
|
227
|
+
}
|
|
228
|
+
const group = split.slice(1, -1);
|
|
229
|
+
matched.push({ token: split, type: "group", groups: this.parsePathTree(group, parent ?? pathname) });
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
if (split.startsWith(":")) {
|
|
233
|
+
if (split.includes('*')) {
|
|
234
|
+
throw new Error(`Unexpected token * of ${split}... in path ${parent ?? pathname}`);
|
|
235
|
+
}
|
|
236
|
+
const [_, ...tokens] = split.split(':');
|
|
237
|
+
if (tokens.length > 1) {
|
|
238
|
+
throw new Error(`Unexpected next tokens : of ${split} in path ${parent ?? pathname}`);
|
|
239
|
+
}
|
|
240
|
+
if (!split.slice(1)) {
|
|
241
|
+
throw new Error(`Missing label of ${split} in path ${parent ?? pathname}`);
|
|
242
|
+
}
|
|
243
|
+
if (matched.at(-1)?.token === "param" || matched.at(-1)?.token === "wildcard") {
|
|
244
|
+
throw new Error(`Unexpected next tokens : of ${split} in path ${parent ?? pathname}`);
|
|
245
|
+
}
|
|
246
|
+
matched.push({ type: "param", token: split });
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
if (split.includes('*')) {
|
|
250
|
+
if (split.includes(':')) {
|
|
251
|
+
throw new Error(`Unexpected token : of ${split}... in path ${parent ?? pathname}`);
|
|
252
|
+
}
|
|
253
|
+
const [_, ...wildcards] = split.split('*');
|
|
254
|
+
if (wildcards.length > 1) {
|
|
255
|
+
throw new Error(`Unexpected next token * of ${split}... in path ${parent ?? pathname}`);
|
|
256
|
+
}
|
|
257
|
+
if (!split.slice(1)) {
|
|
258
|
+
throw new Error(`Missing lable of ${split} in path ${parent ?? pathname}`);
|
|
259
|
+
}
|
|
260
|
+
if (matched.at(-1)?.token === "param" || matched.at(-1)?.token === "wildcard") {
|
|
261
|
+
throw new Error(`Unexpected next tokens : of ${split} in path ${parent ?? pathname}`);
|
|
262
|
+
}
|
|
263
|
+
const wildcard = wildcards[0];
|
|
264
|
+
matched.push({ type: "wildcard", token: `*${wildcard}` });
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
matched.push({ token: split, type: "literal" });
|
|
268
|
+
}
|
|
269
|
+
return matched;
|
|
270
|
+
}
|
|
271
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "path-tree-matcher",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Build and match path trees with parameters, wildcards, and optional route segments.",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"types": "dist/index.d.ts",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"files": [
|
|
9
|
+
"dist"
|
|
10
|
+
],
|
|
11
|
+
"scripts": {
|
|
12
|
+
"test": "node --test",
|
|
13
|
+
"build": "tsgo -p tsconfig.build.json && node ./bin/playground.ts"
|
|
14
|
+
},
|
|
15
|
+
"repository": {
|
|
16
|
+
"type": "git",
|
|
17
|
+
"url": "git+https://github.com/ye-yu/ts-path-tree-matcher.git"
|
|
18
|
+
},
|
|
19
|
+
"keywords": [],
|
|
20
|
+
"author": "",
|
|
21
|
+
"license": "ISC",
|
|
22
|
+
"bugs": {
|
|
23
|
+
"url": "https://github.com/ye-yu/ts-path-tree-matcher/issues"
|
|
24
|
+
},
|
|
25
|
+
"homepage": "https://github.com/ye-yu/ts-path-tree-matcher#readme",
|
|
26
|
+
"devDependencies": {
|
|
27
|
+
"@types/node": "^22.19.19",
|
|
28
|
+
"@typescript/native-preview": "^7.0.0-dev.20260511.1"
|
|
29
|
+
}
|
|
30
|
+
}
|