@jreusch/router
v1.0.3
Published
Core of @jreusch/router, providing framework-agnostic parsing/matching functionality.
Downloads
175
Maintainers
Readme
This is a simple yet powerful routing library with bindings for various frameworks that I use.
This package contains the core logic (parsing and matching patterns and urls), and does not contain any real "router" or framework-specific stuff. It has 0 dependencies aside from built-in support for modern Javascript (modules, regular expressions, etc.) and should be able to run anywhere where there is Javascript.
Features
- 🚀 Tiny - only 300LOC of Typescript, ~1k minified+gziped in production
- 🤘 Well-tested - Lots of tests for the core functionality, used in hundreds of websites
- 🤩 Powerful supports custom patterns, backtracking, capturing arrays, different router backends, and more!
Matching API
The matching API is the core of the libary. It parses patterns
, which are special URLs, and provides functions to match patterns against other URLs, or stringify a pattern back into a URL using the extracted values.
You can learn more about the way patterns and segments work by studying the examples, or looking at the in-depth guide below.
parse(pattern: string): Pattern
parses a pattern string into a pattern, which is the internal representation this library uses to then be able to match or stringify things. The exact pattern syntax and semantics are described below in the in-depth guide.
Patterns support static matches, named variabless, optional and repeatable variables, custom regular expressions, and wildcards.
The syntax is roughly similar to vue-router, if you are familiar with that. Patterns might look like this:
Examples:
parse('/hello/world') // static route
parse('/user/:userId') // simple variable
parse('/posts/:postId?') // optional variable
parse('/user/:userId([0-9]+)') // variable with a custom regex
parse('/*') // wildcard
parse('/:slug*') // named wildcard
parse('/:slug+/edit') // ... with support for backtracking
match(pattern: Pattern, url: string, allowPartial = false): Params|null
Check if a url matches a pattern. If a match is successful, returns an object with all variable values. Repeatable variables will always create an array, regardless of whether or not the value is actually repeated. Optional variables will be undefined
.
Examples:
match(parse('/user/:userId'), '/user/1234') // { userId: '1234' }
match(parse('/posts/:postId?'), '/posts') // {}
match(parse('/posts/:postId?'), '/posts/hello-world') // { postId: 'hello-world' }
match(parse('/:slug*'), '/hello/world') // { slug: ['hello', 'world'] }
match(parse('/:slug+/edit'), '/hello/world/edit') // { slug: ['hello', 'world'] }
match(parse('/:slug+/edit'), '/hello/world/edit/edit') // { slug: ['hello', 'world', 'edit'] }
If you pass allowPartial
, match returns as soons as the pattern is exhausted instead of trying to match the whole URL somehow. While this is useful for checking if a URL starts with a pattern, it might lead to somewhat unexpected params being returned:
// since slug is matched after a single part, this returns { slug: ['hello'] }
// note that it does NOT match the entire URL, and it does NOT return { slug: [] },
// even though '/' would also already satisfy the pattern!
match(parse('/:slug*'), '/hello/world', true) // { slug: ['hello'] }
stringify(pattern: Pattern, params: Params): string
Do the reverse of match, and create an absolute url based on a pattern and some parameters.
Examples:
stringify(parse('/user/:userId'), { userId: 1234 }) // '/user/1234'
stringify(parse('/posts/:postId?'), { postId: null }) // '/posts'
stringify(parse('/posts/:postId?'), { postId: 'hello' }) // '/posts/hello'
encode(url: string): string
Escapes the URL to make sure it is parsed correctly as a static URL.
You do not need to call this function if the URL is encoded by encodeURIComponent
or encodeURI
already, for example, URLs returned by stringify
.
Currently, the only thing that needs to be escaped is colons following slashes:
encode('/:hello world') // ==> '/%2Ahello world'
getUrl(pattern: string, params?: Params): string
Utility function that parses and stringifies the pattern, if params are provided. Otherwise, the provided pattern is encode()
-ed and returned without parsing it further.
getUrl('/hello/:world') // ==> '/%hello/%2Aworld'
getUrl('/hello/:world', { world: 'sailor' }) // ==> /hello/sailor
getUrl('/hello/:world', {}) // ==> /hello
Client-side routers
Routers provide an abstraction similar to HTML5's History
API. They provide the backbone of the various client-side framework integrations. If you want to use this library for server-side routing, don't worry! All of these components are entirely optional and perfectly tree-shakable, and it's perfectly fine and encouraged even to not use the provided router implementations, or none at all!
Using a router is usually wrapped by the framework-specific library for the most part, but using them directly might look like this:
// create a router object
const router = createPathRouter()
// subscribe to URL changes
let url = router.getUrl()
const unsubscribe = router.subscribe(newUrl => {
url = newUrl
})
// programmatically navigate
const params = match(parse('/posts/:postId'), url);
if (params) {
router.navigate(getUrl('/blog/:postId', params), true)
}
createPathRouter(): Router
Create a Router using the location.pathname
property, using the browsers history API under the hood.
createPathRouterWithBase(base: string): Router
Similar to createPathRouter
, but also allows specifying a base url. All routes will be prefixed by that base, and URLs outside of the base will not routed.
createPathRouterWithBase('/') // Same as createPathRouter()
createPathRouterWithBase('/base') // pathname = '/base/hello', getUrl() = '/hello'
createHashRouter(): Router
Creates a Router using the location.hash
property, resulting in URLs like this:
/#/
/widget/#/hello
/index.php#/blog/1234/hello-sailor
Hash-based routing still uses the history API under the hood and is provided for situations where using the pathname is not feasable, not to support pre-historic (pun intended) browsers.
createMemoryRouter(base? string): Router
A router working fully in-memory, suitable for server-side rendering purposes.
You can optionally provide a base
url to change the href urls returned by toHref
.
createMemoryRouter() // basic usage
createMemoryRouter('/base') // <Link href="/path"> ==> <a href="/base/path">
Router API
router.getUrl(): string|null
Get the current URL that should be used to match against.
If null
is returned, the router cannot handle the current URL, for example because the base does not match.
router.toHref(url: string): string
Take a URL (in the form getUrl
would return), and turn it into a "real" URL that can be set as an href attribute on <a>
tags, for example.
router.subscribe(onUrlChange: (newUrl: string|null) => any): () => void
Register a new event handler for whenever the URL changes, either programmatically by calling other router methods, or by some other user interaction. Returns a unsubscribe
function, making it easy to use as an effect hook.
router.navigate(url: string, replace = false)
Navigate to a different url. URL is supposted to be in the format getUrl
returns (and not the one toHref
would produce), so for example if there is a base url, this function would expect to be given a URL without that base.
If replace
is set, replaces the current history entry instead of navigating away.
router.go(delta: number)
Go to a different place in the history, analogous to the browsers function of the same name.
router.go(-1) // go back, if possible
router.go(1) // go forward, if possible
In-depth syntax and semantics
URLs and patterns in this library are generally thought of being slash-separated lists of so-called segments
. Empty segments are by convention always ignored. Let's look at a pattern as an example:
/blog/:postId([0-9]+)/:slug*
This pattern is made up of 3 segments:
['blog', 'postId([0-9]+)', ':slug*']
Every segment can then be categorized into 4 different types: Static, Variable, Custom, and Wildcard, each matching a corresponding URL differently.
So the 2 core functions parse
and match
do exactly that: parse
turns a pattern string into an array of parsed segments, and match
then compares 2 lists of segments, collecting parameter values along the way.
Static Segments
The most basic type of segment is called Static. Static Segments are the pieces of your URL pattern that cannot change, so in our example, blog
would be a static segment. Actual URLs can often be thought of as pattern made up entirely of Static Segments. The <Link>
and useMatch
components provided by the framework libraries work exactly like that, by turning a URL into a valid static pattern, using the encode
function to make sure we do not accidentily run into weird bits in the URL.
Basically, every segment that does not fall into any of the other categories will be parsed as a Static Segment instead.
Static Segments match URL segments only if they are entirely equal to each other, but ignoring case. Our blog
segment might therefore match URLs like this:
/blog
/BLOG
/BlOg/
However, it does NOT match these:
/blo
/blög
/blog-posts
/b/log/
Variable Segments
Variable segments allow you to capture a piece of the URL in a parameter variable, which is the main method dynamic routing is enabled.
Variable segments always start with a colon (':'
) followed by the name of the variable. While it is not enforced, I highly recommend to keep variable names simple, and to use camelCase
names following Javascript conventions.
After the variable name, you can also specify a modifier, changing the way this segment is matched:
| Modifier | Cardinality | Effect |
|----------|-------------|-----------------------------------------------------------------|
| *
| 0-many | variable can match none or multiple segments |
| +
| 1-many | variable can match multiple segments, but must match at least 1 |
| ?
| 0 or 1 | optional variable |
In our example, :slug*
would be a variable segment, setting the parameter slug
, by matching any number of remaining segments.
Here are some URLs this variable would match, including the resulting parameter values:
'/something-wicked-this-way-comes' // ['something-wicked-this-way-comes']
'/here/comes/the/money' // ['here', 'comes', 'the', 'money']
Repeated segments (*
and +
) always always work in a lazy manner, so they only consume as much as they absolutetly need. Matching will always try to first continue with the next segment, backtracking only if that turns out to be impossible.
Optional segments are always tried first before backtracking, so ?
and *
also behave eagerly. For *
, this means that the first match is eagerly done, while subsequent parts will be matched lazily.
The most unexpected property of this is that optional segments that matched something are never backtracked - for example, this pattern does not match:
match(parse('/:a?/:b+'), '/a') // === null
/a
is matched by the first optional segment (:a?
). The :b+
segment is not optional, but fails to match anything since the input got already fully consumed.
Using multiple wildcards in the same pattern might therefore be susceptible to attacks similar to regex-based DOS attacks and should be avoided at all costs!
For the most common case, where you might want to partially match a URL with a wildcard in the middle, you can use the optional allowPartial
argument to match
instead, which will stop eagerly.
Wildcard Segments
A simple variation of variables are wildcard patterns, which are the result of just leaving out the variable name, resulting into patterns like this:
| Wildcard | Description |
|----------|------------------------------------------------|
| /:
| Matches a single segment |
| /*
| Matches anything, or nothing |
| /+
| Matches a single segment, or multiple segments |
| /?
| Matches a single segment, if not at the end |
Note that /?
in patterns has a different special meaning than in URLs, so I recommend always using /:?
or /*
instead.
Custom Segments
Custom Segments extend Variable Segments by allowing you to provide a custom regular expression for that segment. The variable will then only match the URL if the regex also matches.
The URL segment has to match the regex entirely in a case-insensitive manner. Note that the match
algorithm does not use regular expressions under the hood itself, and this is not a way to "paste in" some custom regex code into the middle.
You provide a custom regex by putting it into parentheses after the variable name. Since you most likely provide a string literal here, please remember to double-escape all regex escape patterns, like you would when using the RegExp
constructor (so /\d+/
becomes ':id(\\d+)'
).
Custom Segments can also be combined with modifiers following the regular expression.
// "useless" regex, behaving exacly like a normal variable would
':title(.*)'
// match numeric ids only
':postId([0-9]+)' // matches '/0123', but not '/asdf'
// match UUIDs with nested parens
':uuid([0-9a-f]{8}(-[0-9a-f]{4}){3}-[0-9a-f]{12})'
// match more than 1 single-word segments
':words(\\w+)+'
Support / Climate action
This library was made with ☕, 💦 and 💜 by joshua If you really like what you see, you can Buy me more ☕, or get in touch!
If you work on limiting climate change, preserving the environment, et al. and any of my software is useful to you, please let me know if I can help and prioritize issues!