|
| 1 | +/** |
| 2 | + * Iterative path template matching — no regex. |
| 3 | + * |
| 4 | + * Supports `:param`, `:param?` (optional), and `*splat` segments. |
| 5 | + */ |
| 6 | + |
| 7 | +/** A single parsed segment from a path template. */ |
| 8 | +export type Segment = |
| 9 | + | { type: 'literal'; value: string } |
| 10 | + | { type: 'param'; name: string } |
| 11 | + | { type: 'optional'; name: string } |
| 12 | + | { type: 'splat'; name: string }; |
| 13 | + |
| 14 | +/** Result of a successful path match. */ |
| 15 | +export interface PathMatch { |
| 16 | + params: Record<string, string>; |
| 17 | +} |
| 18 | + |
| 19 | +/** Parse a path template string into typed segments. */ |
| 20 | +export function parseTemplate(path: string): Segment[] { |
| 21 | + return path |
| 22 | + .split('/') |
| 23 | + .filter(Boolean) |
| 24 | + .map((seg): Segment => { |
| 25 | + if (seg.startsWith(':')) { |
| 26 | + const raw = seg.slice(1); |
| 27 | + if (raw.endsWith('?')) { |
| 28 | + return { type: 'optional', name: raw.slice(0, -1) }; |
| 29 | + } |
| 30 | + return { type: 'param', name: raw }; |
| 31 | + } |
| 32 | + if (seg.startsWith('*')) { |
| 33 | + return { type: 'splat', name: seg.slice(1) || 'rest' }; |
| 34 | + } |
| 35 | + return { type: 'literal', value: seg }; |
| 36 | + }); |
| 37 | +} |
| 38 | + |
| 39 | +// Module-level cache: template strings are static, avoid re-parsing. |
| 40 | +const templateCache = new Map<string, Segment[]>(); |
| 41 | + |
| 42 | +function getCachedSegments(template: string): Segment[] { |
| 43 | + let segs = templateCache.get(template); |
| 44 | + if (!segs) { |
| 45 | + segs = parseTemplate(template); |
| 46 | + templateCache.set(template, segs); |
| 47 | + } |
| 48 | + return segs; |
| 49 | +} |
| 50 | + |
| 51 | +/** |
| 52 | + * Try to match a request path against a single route template. |
| 53 | + * |
| 54 | + * Returns bound parameters on success, `null` on failure. |
| 55 | + */ |
| 56 | +export function matchPath( |
| 57 | + template: string, |
| 58 | + requestPath: string, |
| 59 | + exact: boolean, |
| 60 | +): PathMatch | null { |
| 61 | + const segs = getCachedSegments(template); |
| 62 | + const parts = requestPath.split('/').filter(Boolean); |
| 63 | + const params: Record<string, string> = {}; |
| 64 | + let pi = 0; |
| 65 | + |
| 66 | + for (let si = 0; si < segs.length; si++) { |
| 67 | + const seg = segs[si]; |
| 68 | + switch (seg.type) { |
| 69 | + case 'literal': |
| 70 | + if (pi >= parts.length || parts[pi] !== seg.value) return null; |
| 71 | + pi++; |
| 72 | + break; |
| 73 | + case 'param': |
| 74 | + if (pi >= parts.length) return null; |
| 75 | + params[seg.name] = decodeURIComponent(parts[pi++]); |
| 76 | + break; |
| 77 | + case 'optional': |
| 78 | + if (pi < parts.length) { |
| 79 | + params[seg.name] = decodeURIComponent(parts[pi++]); |
| 80 | + } |
| 81 | + break; |
| 82 | + case 'splat': |
| 83 | + params[seg.name] = parts.slice(pi).map(decodeURIComponent).join('/'); |
| 84 | + pi = parts.length; |
| 85 | + break; |
| 86 | + } |
| 87 | + } |
| 88 | + |
| 89 | + if (exact && pi < parts.length) return null; |
| 90 | + return { params }; |
| 91 | +} |
| 92 | + |
| 93 | +/** |
| 94 | + * Count the number of literal (non-param) segments in a template. |
| 95 | + * Used to rank matches by specificity. |
| 96 | + */ |
| 97 | +export function specificity(template: string): number { |
| 98 | + return getCachedSegments(template).filter(s => s.type === 'literal').length; |
| 99 | +} |
0 commit comments