Angular is pretty neat but seems to have a lot of strange blind-spots for simple features, including allowing Angular Router to handle routing in upper- and lower-case. Luckily the solution isn’t too complicated, just typically esoteric.
Well, this is weird
Defining routes in Angular is pretty straightforward and the basic structure will look familiar to anyone coming from a similar platform.
const routes = [ { path: 'about', title: 'About', component: AboutPageComponent }, { path: '**', title: 'Home', component: HomePageComponent } ];
I’d expect there to be a simple argument we could give to the RouterModule or maybe set on a per-route level to make this case-insensitive… maybe we could set path
to a RegExp or something?
Weirdly, no.
But what we can do is use another argument to set a custom match function – matcher
. This takes a UrlMatcher type which is a function intercepting the requested URL info and returning a UrlMatchResult.
The example given in the Angular documentation succinctly explains how this works – there are a lot of potential uses for this!
export function htmlFiles(url: UrlSegment[]) { return url.length === 1 && url[0].path.endsWith('.html') ? ({consumed: url}) : null; } export const routes = [{ matcher: htmlFiles, component: AnyComponent }];
The solution
In our case we really want to replicate the functionality of the built-in matcher function but with both comparands lowercased. Unfortunately it doesn’t seem immediately possible to just wrap the default function, so we’ll quickly replicate it. This is also a useful exercise, as it touches on how the more advanced features of UrlMatcher work.
Our UrlMatcher will have three arguments:
- An array of UrlSegment: basically the requested URL [that remains after any parent routes] split on slashes.
- A UrlSegmentGroup: provides some data on parent and child URL segments.
- The Route we’re testing.
And it will have to return a UrlMatchResult containing:
consumed
, an array of UrlSegments: the segments of the URL that were matched by our function. These will be removed from the UrlSegment array before it is tested against any child routes.- For example, a matcher for the path ‘one/two’ when presented with the URL ‘one/two/three’ should return
[ 'one', 'two' ]
- For example, a matcher for the path ‘one/two’ when presented with the URL ‘one/two/three’ should return
posParams
, an object with UrlSegments keyed by strings: if there are any parameter segments in the input path, this object maps the parameter name to it’s value.- For example, a matcher for the path ‘search/:query/:sort’ when presented with the URL ‘search/shoes/pricedesc’ should return
{ "query": UrlSegment("shoes"), "sort": UrlSegment("pricedesc") }
- For example, a matcher for the path ‘search/:query/:sort’ when presented with the URL ‘search/shoes/pricedesc’ should return
Additionally we have the problem of the path
parameter on the Routes. We can either specify matcher
or path
, not both even though that is really what we want to do here.
My solution below uses closures to offer two ways of applying this to your codebase – one is perhaps more appropriate for making all your routes case insensitive, while the other could be applied sparingly. Please follow along with the code comments to learn what’s happening and see example usage.
Note the LOWERCASE_FORWARD_ROUTE_DATA
constant which controls whether matched segments are passed back to the Router in lowercase and thus whether the users browser has it’s URL rewritten to lowercase.
/** * Enable case-insensitively matching routes. */ const LOWERCASE_ROUTE_MATCH: boolean = true; /** * This will lowercase matched route segments that are returned from this * function, making it rewritee the URL to the lowercase version. Parameters * are unaffected. * * e.g, * ROUTE REQUESTED URL RESULT URL * /product/:productName | /Product/A_Product | /product/A_Product * /search/:arg | /SeArCh/A-fine-search | /search/A-fine-search */ const LOWERCASE_FORWARD_ROUTE_DATA: boolean = true; /** * Takes Routes with `path` set, converts them to use the custom UrlMatcher. * Ideal for wrapping multiple routes e.g, * * const routes = CaseInsensitiveRouteWrapper([ * { * path: 'home', * component: HomeComponent * }, * { * path: 'search/:arg', * component: SearchComponent * } * ]); * * @param Routes one or more Routes * @returns array of new Routes */ export const CaseInsensitiveRouteWrapper = (routes: Route[] | Route): Route[] => { return (Array.isArray(routes) ? routes : [routes]) .map((route: Route) => { if (!route.path || route.matcher || route.path == '**') return route; let newRoute: Route = { ...route, matcher: CaseInsensitiveMatcher(route.path || ''), }; delete newRoute.path; return newRoute; } ); }; /** * Takes the string that would normally be given as `path`. * Use this directly in your routes, e.g, * * { * component: SearchComponent, * matcher: CaseInsensitiveMatcher('search/:arg') * } * * @param path the path to match * @returns a UrlMatcher function */ export const CaseInsensitiveMatcher = (path: string): UrlMatcher => { return ((p) => { return ( segments: UrlSegment[], segmentGroup: UrlSegmentGroup, route: Route ) => { return _caseInsensitiveMatcherProcessor(p, segments, segmentGroup, route); } })(path); }; /** * Does lowercase comparison of routes, and converts to lowercase if desired. * @param url * @returns UrlMatcher fn */ const _caseInsensitiveMatcherProcessor = ( path: string, segments: UrlSegment[], segmentGroup: UrlSegmentGroup, route: Route ): UrlMatchResult | null => { // First we need to split our `path` parameters to consider each segment. const pathSegments = path.split(/\/+/); // Some quick checks can offer us an early exit. if ( pathSegments.length > segments.length || (pathSegments.length !== segments.length && route.pathMatch === 'full') ) { return null; } // Define the return structures. const consumed: UrlSegment[] = []; const posParams: { [name: string]: UrlSegment } = {}; // Loop over the segments of the `path` parameter for (let index = 0; index < pathSegments.length; ++index) { // Get and stringify the UrlSegment, and the path segment. const segment: UrlSegment = segments[index]; const segmentString: string = segment.toString(); const pathSegment = pathSegments[index]; if (pathSegment.startsWith(':')) { // If this is a paramater segment, consume it and store the paramter value. posParams[pathSegment.slice(1)] = segment; consumed.push(segment); } else if ( pathSegment == '**' || (LOWERCASE_ROUTE_MATCH && segmentString.toLowerCase() === pathSegment.toLowerCase()) || (!LOWERCASE_ROUTE_MATCH && segmentString === pathSegment) ) { // Else if this segment is a wildcard, or matches our equality test, consume it. consumed.push( LOWERCASE_FORWARD_ROUTE_DATA ? new UrlSegment(segmentString.toLowerCase(), segment.parameters) : segment ); } else { // No match? End the loop, we've done all we can. break; } } // Return. return { consumed, posParams }; };
I’m sure there will be deviations in behaviour from Angular’s default matcher in this code but hopefully nothing too major.
I hope you enjoyed the post! Please let me know any comments or corrections to what’s presented here.
Until next time…