--- /dev/null
+// @ts-check
+require('./helper')
+const expect = require('expect')
+const { RouterMatcher } = require('../src/matcher')
+const { START_LOCATION_NORMALIZED } = require('../src/types')
+
+const component = null
+
+describe('Router Matcher', () => {
+ describe('resolve', () => {
+ /**
+ *
+ * @param {import('../src/types').RouteRecord} record
+ * @param {import('../src/types').MatcherLocation} location
+ * @param {Partial<import('../src/types').MatcherLocationNormalized>} resolved
+ * @param {import('../src/types').MatcherLocationNormalized} start
+ */
+ function assertRecordMatch(
+ record,
+ location,
+ resolved,
+ start = START_LOCATION_NORMALIZED
+ ) {
+ const matcher = new RouterMatcher([record])
+ const targetLocation = {}
+
+ // add location if provided as it should be the same value
+ if ('path' in location) {
+ resolved.path = location.path
+ }
+
+ // allows not passing params
+ if ('params' in location) {
+ resolved.params = resolved.params || location.params
+ } else {
+ resolved.params = resolved.params || {}
+ }
+
+ const result = matcher.resolve(
+ {
+ ...targetLocation,
+ // override anything provided in location
+ ...location,
+ },
+ start
+ )
+ expect(result).toEqual(resolved)
+ }
+
+ describe('LocationAsPath', () => {
+ it('resolves a normal path', () => {
+ assertRecordMatch(
+ { path: '/', name: 'Home', component },
+ { path: '/' },
+ { name: 'Home', path: '/', params: {} }
+ )
+ })
+
+ it('resolves a path with params', () => {
+ assertRecordMatch(
+ { path: '/users/:id', name: 'User', component },
+ { path: '/users/posva' },
+ { name: 'User', params: { id: 'posva' } }
+ )
+ })
+
+ it('resolves a path with multiple params', () => {
+ assertRecordMatch(
+ { path: '/users/:id/:other', name: 'User', component },
+ { path: '/users/posva/hey' },
+ { name: 'User', params: { id: 'posva', other: 'hey' } }
+ )
+ })
+ })
+
+ describe('LocationAsName', () => {
+ it('matches a name', () => {
+ assertRecordMatch(
+ { path: '/home', name: 'Home', component },
+ { name: 'Home' },
+ { name: 'Home', path: '/home' }
+ )
+ })
+
+ it('matches a name and fill params', () => {
+ assertRecordMatch(
+ { path: '/users/:id/m/:role', name: 'UserEdit', component },
+ { name: 'UserEdit', params: { id: 'posva', role: 'admin' } },
+ { name: 'UserEdit', path: '/users/posva/m/admin' }
+ )
+ })
+ })
+
+ describe('LocationAsRelative', () => {
+ it('matches with nothing', () => {
+ assertRecordMatch(
+ { path: '/home', name: 'Home', component },
+ {},
+ { name: 'Home', path: '/home' },
+ { name: 'Home', params: {}, path: '/home' }
+ )
+ })
+
+ it('replace params', () => {
+ assertRecordMatch(
+ { path: '/users/:id/m/:role', name: 'UserEdit', component },
+ { params: { id: 'posva', role: 'admin' } },
+ { name: 'UserEdit', path: '/users/posva/m/admin' },
+ {
+ path: '/users/ed/m/user',
+ name: 'UserEdit',
+ params: { id: 'ed', role: 'user' },
+ }
+ )
+ })
+ })
+ })
+})
--- /dev/null
+export class NoRouteMatchError extends Error {
+ constructor(currentLocation: any, location: any) {
+ super('No match for' + JSON.stringify({ ...currentLocation, ...location }))
+ Object.setPrototypeOf(this, new.target.prototype)
+ }
+}
RouteRecord,
RouteParams,
MatcherLocation,
- RouterLocationNormalized,
+ MatcherLocationNormalized,
} from './types/index'
-import { stringifyQuery } from './utils'
+import { NoRouteMatchError } from './errors'
// TODO: rename
interface RouteMatcher {
re: RegExp
- resolve: (params: RouteParams) => string
+ resolve: (params?: RouteParams) => string
record: RouteRecord
keys: string[]
}
*/
resolve(
location: Readonly<MatcherLocation>,
- currentLocation: Readonly<RouterLocationNormalized>
- ): RouterLocationNormalized {
- // TODO: type guard HistoryURL
- if ('fullPath' in location)
- return {
- path: location.path,
- fullPath: location.fullPath,
- // TODO: resolve params, query and hash
- params: {},
- query: location.query,
- hash: location.hash,
- }
+ currentLocation: Readonly<MatcherLocationNormalized>
+ // TODO: return type is wrong, should contain fullPath and record/matched
+ ): MatcherLocationNormalized {
+ let matcher: RouteMatcher | void
+ // TODO: refactor with type guards
if ('path' in location) {
- // TODO: warn missing params
- // TODO: extract query and hash? warn about presence
+ // we don't even need currentLocation here
+ matcher = this.matchers.find(m => m.re.test(location.path))
+
+ if (!matcher) throw new NoRouteMatchError(currentLocation, location)
+
+ const params: RouteParams = {}
+ const result = matcher.re.exec(location.path)
+ if (!result) {
+ throw new Error(`Error parsing path "${location.path}"`)
+ }
+
+ for (let i = 0; i < matcher.keys.length; i++) {
+ const key = matcher.keys[i]
+ const value = result[i + 1]
+ if (!value) {
+ throw new Error(
+ `Error parsing path "${
+ location.path
+ }" when looking for key "${key}"`
+ )
+ }
+ params[key] = value
+ }
+
return {
+ name: matcher.record.name,
+ /// no need to resolve the path with the matcher as it was provided
path: location.path,
- // TODO: normalize query?
- query: location.query || {},
- hash: location.hash || '',
- params: {},
- fullPath:
- location.path +
- stringifyQuery(location.query) +
- (location.hash || ''),
+ params,
}
}
- let matcher: RouteMatcher | void
- if (!('name' in location)) {
- // TODO: use current location
- // location = {...location, name: this.}
- if (currentLocation.name) {
- // we don't want to match an undefined name
- matcher = this.matchers.find(
- m => m.record.name === currentLocation.name
- )
- } else {
- matcher = this.matchers.find(m => m.re.test(currentLocation.path))
- }
- // return '/using current location'
- } else {
+ // named route
+ if ('name' in location) {
matcher = this.matchers.find(m => m.record.name === location.name)
+
+ if (!matcher) throw new NoRouteMatchError(currentLocation, location)
+
+ // TODO: try catch for resolve -> missing params
+
+ return {
+ name: location.name,
+ path: matcher.resolve(location.params),
+ params: location.params || {}, // TODO: normalize params
+ }
}
- if (!matcher) {
- // TODO: error
- throw new Error(
- 'No match for' + JSON.stringify({ ...currentLocation, ...location })
- )
+ // location is a relative path
+ if (currentLocation.name) {
+ // we don't want to match an undefined name
+ matcher = this.matchers.find(m => m.record.name === currentLocation.name)
+ } else {
+ // match by path
+ matcher = this.matchers.find(m => m.re.test(currentLocation.path))
}
- // TODO: try catch to show missing params
- const fullPath = matcher.resolve(location.params || {})
+ if (!matcher) throw new NoRouteMatchError(currentLocation, location)
+
return {
- path: fullPath, // TODO: extract path path, query, hash
- fullPath,
- query: {},
- params: {},
- hash: '',
+ name: currentLocation.name,
+ path: matcher.resolve(location.params),
+ params: location.params || {},
}
}
}
import { BaseHistory } from './history/base'
import { RouterMatcher } from './matcher'
import {
- RouterLocation,
+ RouteLocation,
RouteRecord,
START_LOCATION_NORMALIZED,
- RouterLocationNormalized,
+ RouteLocationNormalized,
} from './types/index'
interface RouterOptions {
export class Router {
protected history: BaseHistory
private matcher: RouterMatcher
- currentRoute: RouterLocationNormalized = START_LOCATION_NORMALIZED
+ currentRoute: Readonly<RouteLocationNormalized> = START_LOCATION_NORMALIZED
constructor(options: RouterOptions) {
this.history = options.history
this.history.listen((to, from, info) => {
// TODO: check navigation guards
const url = this.history.parseURL(to)
- this.currentRoute = this.matcher.resolve(url, this.currentRoute)
+ const matchedRoute = this.matcher.resolve(url, this.currentRoute)
+ console.log({ url, matchedRoute })
+ // TODO: navigate
})
}
* Trigger a navigation, should resolve all guards first
* @param to Where to go
*/
- push(to: RouterLocation) {
+ push(to: RouteLocation) {
// TODO: resolve URL
const url = typeof to === 'string' ? this.history.parseURL(to) : to
const location = this.matcher.resolve(url, this.currentRoute)
+ console.log(location)
// TODO: call hooks, guards
- this.history.push(location.fullPath)
- this.currentRoute = location
+ // TODO: navigate
+ // this.history.push(location.fullPath)
+ // this.currentRoute = location
}
- getRouteRecord(location: RouterLocation) {}
+ getRouteRecord(location: RouteLocation) {}
}
-import { HistoryURL } from '../history/base'
-
type TODO = any
+// TODO: support numbers for easier writing but cast them
export type RouteParams = Record<string, string | string[]>
export type RouteQuery = Record<string, string | string[] | null>
+export interface RouteQueryAndHash {
+ query?: RouteQuery
+ hash?: string
+}
+export interface LocationAsPath {
+ path: string
+}
+
+export interface LocationAsName {
+ name: string
+ params?: RouteParams
+}
+
+export interface LocationAsRelative {
+ params?: RouteParams
+}
+
+// User level location
+export type RouteLocation =
+ | string
+ | RouteQueryAndHash & LocationAsPath
+ | RouteQueryAndHash & LocationAsName
+ | RouteQueryAndHash & LocationAsRelative
+
+// the matcher doesn't care about query and hash
+export type MatcherLocation =
+ | LocationAsPath
+ | LocationAsName
+ | LocationAsRelative
+
+// exposed to the user in a very consistant way
+export interface RouteLocationNormalized {
+ path: string
+ fullPath: string
+ name: string | void
+ params: RouteParams
+ query: RouteQuery
+ hash: string
+}
+
// interface PropsTransformer {
// (params: RouteParams): any
// }
// props: PT
}
-type RouteObjectLocation =
- | {
- // no params because they must be provided by the user
- path: string
- query?: RouteQuery
- hash?: string
- }
- | {
- // named location
- name: string
- params?: RouteParams
- query?: RouteQuery
- hash?: string
- }
- | {
- // relative location
- params?: RouteParams
- query?: RouteQuery
- hash?: string
- }
-
-// TODO: location should be an object
-export type MatcherLocation = HistoryURL | RouteObjectLocation
-
-export type RouterLocation = string | RouteObjectLocation
-
-export interface RouterLocationNormalized {
- path: string
- fullPath: string
- name?: string
- params: RouteParams
- query: RouteQuery
- hash: string
-}
-
export const START_RECORD: RouteRecord = {
path: '/',
// @ts-ignore
component: { render: h => h() },
}
-export const START_LOCATION_NORMALIZED: RouterLocationNormalized = {
+export const START_LOCATION_NORMALIZED: RouteLocationNormalized = {
path: '/',
+ name: undefined,
params: {},
query: {},
hash: '',
fullPath: '/',
}
+
+// Matcher types
+// TODO: can probably have types with no record, path and others
+// should be an & type
+export interface MatcherLocationNormalized {
+ name: RouteLocationNormalized['name']
+ path: string
+ // record?
+ params: RouteLocationNormalized['params']
+}