Last active
October 16, 2022 17:14
-
-
Save mattmccray/c022024012861e294444c1f3f0d371c5 to your computer and use it in GitHub Desktop.
A zero dependency, tightly focused, micro router.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
interface PathProvider { | |
getPath(): string | |
setPath(path: string): void | |
onPathChange(callback: (path: string) => void): void | |
} | |
interface CurrentMatch { | |
path: string | |
route: Route | null | |
params: Record<string, string> | |
} | |
interface CurrentMatchHandler { | |
(info: CurrentMatch): void | |
} | |
const NOOP = (params: any) => { } | |
export class Router { | |
protected routes = new Map<string, Route>() | |
protected provider?: PathProvider | |
protected lastMatchState: string | null = null | |
protected changeListeners = new Set<CurrentMatchHandler>() | |
current: CurrentMatch = { | |
path: '', | |
route: null, | |
params: {}, | |
} | |
define = (name: string, path: string, callback: (params: Record<string, string>) => void = NOOP) => { | |
assert(!this.routes.has(name), "[Router] Name %s is already defined.", name) | |
this.routes.set(name, new Route(name, path, callback)) | |
return this | |
} | |
resolve(name: string, params: Record<string, any> = {}) { | |
const route = this.routes.get(name) | |
assert(!!route, "[Router] Route named %s not found.", name) | |
return route!.resolve(params) | |
} | |
process = (path: any) => { | |
for (let route of this.routes.values()) { | |
const params = route.match(path) | |
if (params !== null) { | |
const paramsJson = JSON.stringify(params) | |
if (paramsJson == this.lastMatchState) { | |
return | |
} | |
this.current = { | |
path, route, params | |
} | |
route.callback(params) | |
this.lastMatchState = paramsJson | |
this.changeListeners.forEach(cb => cb(this.current)) | |
return true | |
} | |
} | |
return false | |
} | |
navigate(name: string, params: Record<string, any> = {}) { | |
const path = this.resolve(name, params) | |
this.navigateToPath(path) | |
} | |
navigateToPath(path: string) { | |
this.provider?.setPath(path) | |
} | |
onChange(callback: CurrentMatchHandler) { | |
this.changeListeners.add(callback) | |
return () => { | |
this.changeListeners.delete(callback) | |
} | |
} | |
clearListeners() { this.changeListeners.clear() } | |
start(provider: PathProvider = new HashPathProvider()) { | |
this.provider = provider | |
this.process(provider.getPath()) | |
provider.onPathChange(this.process) | |
} | |
} | |
export class HashPathProvider implements PathProvider { | |
getPath(): string { | |
return String(document.location.hash || "#/").slice(1) | |
} | |
setPath(path: string): void { | |
document.location.hash = '#' + path | |
} | |
onPathChange(callback: (path: string) => void) { | |
window.addEventListener('hashchange', e => { | |
const path = this.getPath() | |
callback(path) | |
}) | |
} | |
} | |
class Route { | |
namedParameters!: string[] | |
protected reString!: string | |
protected regexp!: RegExp | |
constructor( | |
public name: string, | |
public definition: string, | |
public callback: (params: Record<string, string>) => void | |
) { | |
this._parseDefinition() | |
} | |
resolve(params: Record<string, any> = {}) { | |
const missing = this.namedParameters.filter(name => !Reflect.has(params, name)) | |
assert(missing.length == 0, "[Route] Missing parameters for route %s: '%s'", this.name, missing.join("', '")) | |
let path = this.definition. | |
replace(/\/:\w+\?/g, (i: any) => { | |
let param = params[i.slice(2).slice(0, -1)] | |
if (param) { | |
return '/' + encodeURIComponent(param) | |
} else { | |
return '' | |
} | |
}) | |
.replace(/\/:\w+/g, i => '/' + encodeURIComponent(params[i.slice(2)])) | |
const extraKeys = Reflect | |
.ownKeys(params) | |
.map(String) | |
.filter((key) => !this.namedParameters.includes(key)) | |
if (extraKeys.length > 0) { | |
path += '?' + extraKeys.map(key => `${encodeURIComponent(key)}=${encodeURIComponent(params[key])}`).join("&") | |
} | |
return path | |
} | |
match(targetPath: string): Record<string, string> | null { | |
let [path, query] = targetPath.split('?') | |
const match = path.match(this.regexp) | |
if (!match) return null | |
const matches = match.slice(1) | |
const params = matches.reduce((params, match, index) => { | |
params[this.namedParameters[index]] = decodeURIComponent(match) | |
return params | |
}, {} as Record<string, string>) | |
if (!!query) query.split('&').forEach(pair => { | |
const [name, value] = pair.split('=') | |
params[decodeURIComponent(name)] = decodeURIComponent(value) | |
}) | |
return params | |
} | |
protected _parseDefinition() { | |
let value = this.definition.replace(/\/$/g, '') || '/' | |
this.namedParameters = (value.match(/\/:\w+/g) || []).map(i => i.slice(2)) | |
this.reString = value | |
.replace(/[\s!#$()+,.:<=?[\\\]^{|}]/g, '\\$&') | |
.replace(/\/\\:\w+\\\?/g, '/?([^/]*)') | |
.replace(/\/\\:\w+/g, '/([^/]+)') | |
this.regexp = RegExp('^' + this.reString + '$', 'i') | |
} | |
} | |
export function assert(condition: boolean, format?: string, a?: any, b?: any, c?: any, d?: any, e?: any, f?: any) { | |
if (!condition) { | |
let error: Error | |
if (format === undefined) { // TODO: if you are using a build tool, an env=='development' check here would be good. | |
error = new Error("Unexpected state detected. (Use dev version for full error messages)") | |
} | |
else { | |
var args = [a, b, c, d, e, f] | |
var argIndex = 0 | |
error = new AssertError( | |
format.replace(/%s/g, function () { return args[argIndex++] }) | |
) | |
error.name = 'Assertion Failure' | |
} | |
//@ts-ignore | |
error.framesToPop = 1 | |
throw error | |
} | |
} | |
export class AssertError extends Error { } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
I use this in SPA projects (thus only shipping with
HashPathProvider
).Example usage:
You can create link paths using
resolve
.If you care about every byte.... You can remove the assertion checks and function to get it to a 1.0 KB min/gzip size.