UNPKG

@wroud/navigation

Version:

A flexible, pattern-matching navigation system for JavaScript applications with built-in routing, browser integration, and navigation state management

334 lines (288 loc) 9.68 kB
import type { RouteParams } from "../IRouteMatcher.js"; import { TrieNode } from "./TrieNode.js"; import { convertParamValue } from "./parameter-utils.js"; import type { ExtendedMatchResult } from "./types.js"; // Default empty match result to use instead of undefined const emptyMatchResult: ExtendedMatchResult = { matched: false, params: {} }; /** * Matches a segment against a static child node * * @param node Current node in the trie * @param segment Current segment to match * @param segments All segments array * @param index Current index in segments * @param params Accumulated parameters * @returns Array of match results * * Converts the current segment to the correct primitive type * using the parameter type stored on the trie node. */ function matchStaticSegment( node: TrieNode, segment: string, segments: string[], index: number, params: RouteParams, ): ExtendedMatchResult[] { if (!node.hasStaticChild(segment)) return []; const childNode = node.getStaticChild(segment); if (!childNode) return []; const result = matchSegments(childNode, segments, index + 1, params); return result.matched ? [result] : []; } /** * Matches a segment against a parameter node * * @param node Current node in the trie * @param segment Current segment to match * @param segments All segments array * @param index Current index in segments * @param params Accumulated parameters * @returns Array of match results * * Consumes one or more segments and converts them to the * declared parameter type stored on the wildcard node. */ function matchParameterSegment( node: TrieNode, segment: string, segments: string[], index: number, params: RouteParams, ): ExtendedMatchResult[] { if (!node.paramChild) return []; const paramName = node.paramChild.name; const paramType = node.paramChild.paramType; const value = convertParamValue(segment, paramType); const newParams = { ...params, [paramName]: value }; const result = matchSegments(node.paramChild, segments, index + 1, newParams); return result.matched ? [result] : []; } /** * Matches segments against a wildcard node using an optimized algorithm * * @param node Current node in the trie * @param segments All segments array * @param index Current index in segments * @param params Accumulated parameters * @returns Array of match results */ function matchWildcardSegment( node: TrieNode, segments: string[], index: number, params: RouteParams, ): ExtendedMatchResult[] { if (!node.wildcardChild) return []; const paramName = node.wildcardChild.name; const paramType = node.wildcardChild.paramType; const remainingSegments = segments.length - index; if (remainingSegments <= 0) return []; const results: ExtendedMatchResult[] = []; // Optimization for wildcards at the end of pattern if (node.wildcardChild.isEndOfPattern) { const validSegments = segments.slice(index).map((s) => convertParamValue(s, paramType)); if (!validSegments.includes(undefined as any)) { results.push({ matched: true, params: { ...params, [paramName]: validSegments }, pattern: node.wildcardChild.pattern || undefined, }); } return results; } // For wildcards in the middle - try different consumption strategies // 1. Non-greedy (minimal) match - just one segment const segment = segments[index]; if (segment !== undefined) { const nonGreedyParams = { ...params, [paramName]: [convertParamValue(segment, paramType)], }; const nonGreedyResult = matchSegments( node.wildcardChild, segments, index + 1, nonGreedyParams, ); if (nonGreedyResult.matched) { results.push(nonGreedyResult); } } // 2. Greedy match - try consuming multiple segments if (remainingSegments > 1) { // Start with max segments and work backwards for efficiency for (let i = remainingSegments; i > 1; i--) { const consumedSegments = segments .slice(index, index + i) .map((s) => convertParamValue(s, paramType)); if (consumedSegments.includes(undefined as any)) continue; const greedyParams = { ...params, [paramName]: consumedSegments }; const greedyResult = matchSegments( node.wildcardChild, segments, index + i, greedyParams, ); if (greedyResult.matched) { results.push(greedyResult); break; // Found a match, stop trying other segment counts } } } return results; } // Cache for pattern type analysis const patternTypeCache = new Map< string, { hasParams: boolean; hasWildcard: boolean; staticSegmentCount?: number } >(); /** * Analyzes a pattern and caches its characteristics for faster matching * @param pattern The pattern to analyze * @returns Pattern characteristics */ function getPatternType(pattern: string | undefined) { if (!pattern) { return { hasParams: false, hasWildcard: false }; } // Return cached result if available const cached = patternTypeCache.get(pattern); if (cached) return cached; // Analyze pattern and cache result const result = { hasParams: pattern.includes(":"), hasWildcard: pattern.includes("*"), }; patternTypeCache.set(pattern, result); return result; } function getStaticSegmentCount(pattern: string): number { // Check if we've already computed this const cached = patternTypeCache.get(pattern); if (cached?.staticSegmentCount !== undefined) { return cached.staticSegmentCount; } // Count static segments (not starting with ":") const count = pattern .split("/") .filter((s) => s && !s.startsWith(":")).length; // Store result in cache if (cached) { cached.staticSegmentCount = count; } return count; } /** * Match a segment array against a trie, starting from the given node * * This function recursively traverses the trie to find a matching pattern * for the given segments. * * @param node The current node in the trie * @param segments The URL segments to match * @param index The current segment index * @param params The accumulated parameters * @returns Match result with parameters and pattern */ export function matchSegments( node: TrieNode, segments: string[], index: number, params: RouteParams, ): ExtendedMatchResult { // End of segments - check if this is a valid pattern end if (index === segments.length) { return node.isEndOfPattern ? { matched: true, params, pattern: node.pattern || undefined } : emptyMatchResult; } const segment = segments[index]; // Handle empty segments if (segment === "") { // Special case for root path "/" if (index === 0 && node.hasStaticChild("")) { const emptyPathNode = node.getStaticChild(""); if (emptyPathNode) { const result = matchSegments( emptyPathNode, segments, index + 1, params, ); if (result.matched) return result; } } // Skip other empty segments if not at start if (index > 0) return emptyMatchResult; } // Skip undefined segments if (segment === undefined) return emptyMatchResult; // Static matches have highest priority const staticResults = matchStaticSegment( node, segment, segments, index, params, ); if (staticResults.length === 1) return staticResults[0]!; const paramResults = matchParameterSegment( node, segment, segments, index, params, ); // Optimization: Skip wildcard matching if we already have results and no wildcard node if ( (staticResults.length > 0 || paramResults.length > 0) && !node.wildcardChild ) { const results = [...staticResults, ...paramResults]; if (results.length === 1) return results[0]!; const sortedResults = sortMatchResults(results); return sortedResults.length > 0 ? sortedResults[0]! : emptyMatchResult; } const wildcardResults = matchWildcardSegment(node, segments, index, params); const allResults = [...staticResults, ...paramResults, ...wildcardResults]; if (allResults.length === 0) return emptyMatchResult; if (allResults.length === 1) return allResults[0]!; return sortMatchResults(allResults)[0]!; } /** * Sort match results by specificity: * 1. Static routes * 2. Parameter routes with more segments * 3. Wildcard routes * * @param results Array of match results to sort * @returns Sorted array of match results */ export function sortMatchResults( results: ExtendedMatchResult[], ): ExtendedMatchResult[] { if (results.length <= 1) return results; return results.sort((a, b) => { // First, handle cases where one or both patterns might be undefined if (a.pattern && !b.pattern) return -1; if (!a.pattern && b.pattern) return 1; if (!a.pattern || !b.pattern) return 0; // Get cached pattern analysis const aType = getPatternType(a.pattern); const bType = getPatternType(b.pattern); // Priority order: static > parameter > wildcard // First compare by pattern type if (!aType.hasParams && bType.hasParams) return -1; // Static over param if (aType.hasParams && !bType.hasParams) return 1; // Param under static if (!aType.hasWildcard && bType.hasWildcard) return -1; // Non-wildcard over wildcard if (aType.hasWildcard && !bType.hasWildcard) return 1; // Wildcard under non-wildcard // If both are the same type, prioritize by number of static segments if (aType.hasWildcard && bType.hasWildcard) { return ( getStaticSegmentCount(b.pattern) - getStaticSegmentCount(a.pattern) ); } return 0; }); }