@wroud/navigation
Version:
A flexible, pattern-matching navigation system for JavaScript applications with built-in routing, browser integration, and navigation state management
306 lines • 10.2 kB
JavaScript
import { TrieNode } from "./TrieNode.js";
import { matchSegments } from "./matcher.js";
import { extractParamName, extractParamType, isParameterSegment, isWildcardSegment, joinPath, splitPath, } from "./path-utils.js";
import { buildUrlSegments, validateParameters } from "./parameter-utils.js";
/**
* Trie-based implementation for URL pattern matching.
* Supports static segments, parameter segments (:param), and wildcard segments (:param*).
*/
export class TriePatternMatching {
root;
patterns;
// Direct mapping from patterns to nodes for fast access
patternToNode;
// Cache for frequently decoded URLs
decodeCache;
// Cache for frequently encoded patterns
encodeCache;
// Maximum cache size to prevent memory leaks
MAX_CACHE_SIZE = 1000;
options;
constructor(options = {}) {
this.root = new TrieNode();
this.patterns = new Set();
this.patternToNode = new Map();
this.decodeCache = new Map();
this.encodeCache = new Map();
this.options = {
trailingSlash: true,
...options,
};
}
/**
* Add a pattern to the trie (e.g., "/user/:id<number>")
*
* Parameter nodes store declared type information which
* is later used to convert matched values.
*/
addPattern(pattern) {
if (this.patterns.has(pattern))
return;
this.patterns.add(pattern);
const segments = splitPath(pattern);
let current = this.root;
for (const segment of segments) {
if (!segment) {
// Handle root path
if (pattern === "/" && current === this.root) {
current = current.addStaticChild("");
}
continue;
}
if (isParameterSegment(segment)) {
const paramName = extractParamName(segment);
const paramType = extractParamType(segment);
current = isWildcardSegment(segment)
? current.getOrCreateWildcardChild(paramName, paramType)
: current.getOrCreateParamChild(paramName, paramType);
}
else {
current = current.addStaticChild(segment);
}
}
current.markAsPatternEnd(pattern);
// Store direct reference to the node
this.patternToNode.set(pattern, current);
this.clearCaches();
}
/**
* Type guard to check if a route state matches a specific route ID
*/
isRoute(state, id) {
if (!state)
return false;
return state.id === id;
}
/**
* Match a URL against the patterns in the trie
*/
match(url) {
const segments = splitPath(this.removeBaseFromUrl(url));
const { matched, pattern, params } = matchSegments(this.root, segments, 0, {});
if (!matched || !pattern)
return null;
return {
id: pattern,
params: params,
};
}
/**
* Decode URL parameters based on a pattern
*/
decode(pattern, url) {
url = this.addBaseToUrl(url);
// Check cache first
const cached = this.decodeCache.get(pattern)?.get(url);
if (cached !== undefined)
return cached;
// Register pattern if needed
if (!this.patterns.has(pattern)) {
this.addPattern(pattern);
}
// Match URL against pattern
const matchResult = this.match(url);
const result = !matchResult || matchResult.id !== pattern ? null : matchResult.params;
// Update cache
this.updateCache(this.decodeCache, pattern, url, result);
return result;
}
/**
* Encode parameters into a URL based on a pattern
*/
encode(pattern, params) {
// Special case for root path
if (pattern === "/") {
return "/";
}
const paramsKey = JSON.stringify(params);
// Check cache
const patternCache = this.encodeCache.get(pattern);
const cached = patternCache?.get(paramsKey);
// Only proceed with encoding if the cached value is undefined
// (not null or empty string, which are valid cached results)
if (cached !== undefined)
return cached;
const segments = splitPath(pattern);
const paramTypes = this.extractParameterTypes(pattern, segments);
validateParameters(pattern, segments, params, paramTypes);
const resultSegments = buildUrlSegments(segments, params, paramTypes);
let result = this.addBaseToUrl(joinPath(resultSegments));
if (!this.options.trailingSlash) {
result = result.replace(/\/$/, "");
}
else if (!result.endsWith("/")) {
result += "/";
}
// Update cache
this.updateCache(this.encodeCache, pattern, paramsKey, result);
return result;
}
/**
* Explicitly clear all caches
*/
clearCaches() {
this.decodeCache.clear();
this.encodeCache.clear();
}
/**
* Get all registered patterns
*/
getPatterns() {
return [...this.patterns];
}
/**
* Get potential parent patterns for a given pattern
*/
getPatternAncestors(pattern) {
if (pattern === "/") {
return []; // Root has no parents
}
// Get the node for this pattern - direct lookup from map
const node = this.patternToNode.get(pattern);
if (!node)
return [];
// Use the node's findAncestorPatterns method to get ancestors
return node.findAncestorPatterns();
}
/**
* Find all patterns that could be considered child patterns of the given pattern
*/
getPatternDescendants(pattern) {
// Get the node for this pattern - direct lookup from map
const node = this.patternToNode.get(pattern);
if (!node)
return [];
// Collect all patterns that end in nodes below this one
return this.collectDescendantPatterns(node);
}
/**
* Collect all patterns that end in nodes below a given node
*/
collectDescendantPatterns(node) {
const patterns = [];
const traverse = (current) => {
// If this node has a pattern and it's not the starting node's pattern,
// add it to the results
if (current !== node && current.isEndOfPattern && current.pattern) {
patterns.push(current.pattern);
}
// Traverse all children
for (const [_, child] of current.children) {
traverse(child);
}
// Check parameter children
if (current.paramChild) {
traverse(current.paramChild);
}
// Check wildcard children
if (current.wildcardChild) {
traverse(current.wildcardChild);
}
};
// Start DFS traversal from the node
traverse(node);
return patterns;
}
/**
* Update a cache with a new result
*/
updateCache(cache, pattern, key, value) {
const cacheMap = this.ensureCacheMap(cache, pattern);
this.evictCacheIfNeeded(cacheMap);
cacheMap.set(key, value);
}
/**
* Ensure a cache map exists for the given key, evicting the oldest entry if needed
*/
ensureCacheMap(cache, key) {
if (!cache.has(key)) {
// Check if we need to evict the oldest entry due to cache size
this.evictCacheIfNeeded(cache);
cache.set(key, new Map());
}
return cache.get(key);
}
/**
* Extract parameter types from a pattern by analyzing its segments
*/
extractParameterTypes(pattern, segments) {
const paramTypes = {};
segments.forEach((segment) => {
if (isParameterSegment(segment)) {
const paramName = extractParamName(segment);
const paramType = extractParamType(segment);
paramTypes[paramName] = paramType;
}
});
return paramTypes;
}
/**
* Helper method to evict oldest cache entry if size limit is reached
*/
evictCacheIfNeeded(cache) {
if (cache.size >= this.MAX_CACHE_SIZE) {
const iterator = cache.keys().next();
if (iterator.value !== undefined) {
cache.delete(iterator.value);
}
}
}
/**
* Remove a pattern from the trie
*/
removePattern(pattern) {
if (!this.patterns.has(pattern)) {
return;
}
// Get the node for this pattern
const node = this.patternToNode.get(pattern);
if (node) {
// Just unmark it as a pattern end
node.isEndOfPattern = false;
node.pattern = null;
}
// Remove from maps and sets
this.patterns.delete(pattern);
this.patternToNode.delete(pattern);
this.clearCaches();
}
/**
* Convert a URL to a route state
*/
urlToState(url) {
return this.match(url);
}
/**
* Convert a route state to a URL
*/
stateToUrl(state) {
if (!this.patterns.has(state.id))
return null;
// Simplified - encode() already handles validation and will return proper results
return this.encode(state.id, state.params);
}
/**
* Remove base path from URL if present
*/
removeBaseFromUrl(url) {
if (this.options.base && url.startsWith(this.options.base)) {
return url.replace(this.options.base, "");
}
return url;
}
/**
* Add base path to URL if configured
*/
addBaseToUrl(url) {
if (this.options.base && url.startsWith("/")) {
if (this.options.base.endsWith("/")) {
return this.options.base + url.replace(/^\//, "");
}
return this.options.base + url;
}
return url;
}
}
//# sourceMappingURL=TriePatternMatching.js.map