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 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/`
@@ -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
+ }