it('can be instantiated', () => {
const history = new HTML5History()
- expect(history.location).toBe('/')
+ expect(history.location).toEqual({
+ fullPath: '/',
+ path: '/',
+ query: {},
+ hash: '',
+ })
})
})
// @ts-check
require('./helper')
const expect = require('expect')
-const { BaseHistory } = require('../src/history/base')
-
-const parseURL = BaseHistory.prototype.parseURL
+const { parseURL } = require('../src/history/utils')
describe('URL parsing', () => {
it('works with no query no hash', () => {
import consola from 'consola'
-import { BaseHistory } from './base'
-import { HistoryLocation, NavigationCallback, HistoryState } from './base'
+import { BaseHistory, HistoryLocationNormalized } from './base'
+import { NavigationCallback, HistoryState } from './base'
const cs = consola.withTag('abstract')
// TODO: is this necessary
ensureLocation() {}
- replace(to: HistoryLocation) {}
+ replace(to: HistoryLocationNormalized) {}
- push(to: HistoryLocation, data?: HistoryState) {}
+ push(to: HistoryLocationNormalized, data?: HistoryState) {}
listen(callback: NavigationCallback) {
return () => {}
-export type HistoryLocation = string
+import * as utils from './utils'
export type HistoryQuery = Record<string, string | string[]>
-export interface HistoryURL {
- // full path (like href)
- fullPath: string
+
+export interface HistoryLocation {
// pathname section
path: string
// search string parsed
- query: HistoryQuery
+ query?: HistoryQuery
// hash with the #
- hash: string
+ hash?: string
+}
+export interface HistoryLocationNormalized extends Required<HistoryLocation> {
+ // full path (like href)
+ fullPath: string
}
// pushState clones the state passed and do not accept everything
-// it doesn't accept symbols, nor functions. It also ignores Symbols as keys
+// it doesn't accept symbols, nor functions as values. It also ignores Symbols as keys
type HistoryStateValue =
| string
| number
interface HistoryStateArray extends Array<HistoryStateValue> {}
// export type HistoryState = Record<string | number, string | number | boolean | undefined | null |
-export const START: HistoryLocation = '/'
+export const START: HistoryLocationNormalized = {
+ fullPath: '/',
+ path: '/',
+ query: {},
+ hash: '',
+}
export enum NavigationType {
// NOTE: is it better to have strings?
export interface NavigationCallback {
(
- to: HistoryLocation,
- from: HistoryLocation,
+ to: HistoryLocationNormalized,
+ from: HistoryLocationNormalized,
info: { type: NavigationType }
): void
}
export type RemoveListener = () => void
-const PERCENT_RE = /%/g
-
export abstract class BaseHistory {
// previousState: object
- location: HistoryLocation = START
+ location: HistoryLocationNormalized = START
base: string = ''
+ utils = utils
/**
* Sync source with a different location.
*/
abstract listen(callback: NavigationCallback): RemoveListener
- /**
- * Transforms a URL into an object
- * @param location location to normalize
- * @param currentLocation current location, to reuse params and location
- */
- parseURL(location: string): HistoryURL {
- let path = '',
- query: HistoryURL['query'] = {},
- searchString = '',
- hash = ''
-
- // Could use URL and URLSearchParams but IE 11 doesn't support it
- // TODO: move this utility to base.ts so it can be used by any history implementation
- const searchPos = location.indexOf('?')
- const hashPos = location.indexOf('#', searchPos > -1 ? searchPos : 0)
- if (searchPos > -1) {
- path = location.slice(0, searchPos)
- searchString = location.slice(
- searchPos + 1,
- hashPos > -1 ? hashPos : location.length
- )
-
- // TODO: properly do this in a util function
- query = searchString.split('&').reduce((query, entry) => {
- const [key, value] = entry.split('=')
- if (key in query) {
- // an extra variable for ts types
- let currentValue = query[key]
- if (!Array.isArray(currentValue)) {
- currentValue = query[key] = [currentValue]
- }
- currentValue.push(value)
- } else {
- query[key] = value
- }
-
- return query
- }, query)
- }
-
- if (hashPos > -1) {
- path = path || location.slice(0, hashPos)
- hash = location.slice(hashPos, location.length)
- }
-
- path = path || location
-
- return {
- fullPath: location,
- path,
- query,
- hash,
- }
- }
-
- /**
- * Stringify a URL object
- * @param location
- */
- stringifyURL(location: {
- path: string
- query?: HistoryQuery
- hash?: string
- }): string {
- let url = location.path
- let query = '?'
- // TODO: util function?
- for (const key in location.query) {
- if (query.length > 1) query += '&'
- // TODO: handle array
- const value = location.query[key]
- if (Array.isArray(value)) {
- query += `${key}=${value[0]}`
- for (let i = 1; i < value.length; i++) {
- query += `&${key}=${value[i]}`
- }
- } else {
- query += `${key}=${location.query[key]}`
- }
- }
-
- if (query.length > 1) url += query
-
- return url + (location.hash || '')
- }
-
- /**
- * Prepare a URI string to be passed to pushState
- * @param uri
- */
- prepareURI(uri: string) {
- // encode the % symbol so it also works on IE
- return uri.replace(PERCENT_RE, '%25')
- }
-
- // use regular decodeURI
- decodeURI = decodeURI
-
/**
* ensure the current location matches the external source
* For example, in HTML5 and hash history, that would be
import consola from 'consola'
-import { BaseHistory } from './base'
-import {
- HistoryLocation,
- NavigationCallback,
- HistoryState,
- NavigationType,
-} from './base'
+import { BaseHistory, HistoryLocationNormalized, HistoryLocation } from './base'
+import { NavigationCallback, HistoryState, NavigationType } from './base'
const cs = consola.withTag('html5')
type PopStateListener = (this: Window, ev: PopStateEvent) => any
interface StateEntry {
- back: HistoryLocation | null
- current: HistoryLocation
- forward: HistoryLocation | null
+ back: HistoryLocationNormalized | null
+ current: HistoryLocationNormalized
+ forward: HistoryLocationNormalized | null
replaced: boolean
}
// TODO: pretty useless right now except for typing
function buildState(
- back: HistoryLocation | null,
- current: HistoryLocation,
- forward: HistoryLocation | null,
+ back: HistoryLocationNormalized | null,
+ current: HistoryLocationNormalized,
+ forward: HistoryLocationNormalized | null,
replaced: boolean = false
): StateEntry {
return {
export class HTML5History extends BaseHistory {
private history = window.history
- location: HistoryLocation
private _popStateHandler: PopStateListener
private _listeners: NavigationCallback[] = []
private _teardowns: Array<() => void> = []
super()
const to = buildFullPath()
// cs.log('created', to)
- this.history.replaceState(buildState(null, to, null), '', to)
+ this.history.replaceState(buildState(null, to, null), '', to.fullPath)
this.location = to
this._popStateHandler = this.setupPopStateListener()
}
ensureLocation() {}
replace(to: HistoryLocation) {
- // TODO: standarize URL
- if (to === this.location) return
- cs.info('replace', this.location, to)
+ const normalized = this.utils.normalizeLocation(to)
+ if (normalized.fullPath === this.location.fullPath) return
+ cs.info('replace', this.location, normalized)
this.history.replaceState(
// TODO: this should be user's responsibility
// _replacedState: this.history.state || null,
- buildState(this.history.state.back, to, null, true),
+ buildState(this.history.state.back, normalized, null, true),
'',
- to
+ normalized.fullPath
)
- this.location = to
+ this.location = normalized
}
push(to: HistoryLocation, data?: HistoryState) {
// replace current entry state to add the forward value
+ const normalized = this.utils.normalizeLocation(to)
this.history.replaceState(
buildState(
this.history.state.back,
this.history.state.current,
- to,
+ normalized,
this.history.state.replaced
),
''
// NEW NOTE: I think it shouldn't be history responsibility to check that
// if (to === this.location) return
const state = {
- ...buildState(this.location, to, null),
+ ...buildState(this.location, normalized, null),
...data,
}
cs.info('push', this.location, '->', to, 'with state', state)
- this.history.pushState(state, '', to)
- this.location = to
+ this.history.pushState(state, '', normalized.fullPath)
+ this.location = normalized
}
listen(callback: NavigationCallback) {
}
}
-const buildFullPath = () =>
- window.location.pathname + window.location.search + window.location.hash
+const buildFullPath = () => {
+ const { location } = window
+ return {
+ fullPath: location.pathname + location.search + location.hash,
+ path: location.pathname,
+ query: {}, // TODO: parseQuery
+ hash: location.hash,
+ }
+}
--- /dev/null
+import {
+ HistoryLocationNormalized,
+ HistoryQuery,
+ HistoryLocation,
+} from './base'
+
+const PERCENT_RE = /%/g
+
+/**
+ * Transforms a URL into an object
+ * @param location location to normalize
+ * @param currentLocation current location, to reuse params and location
+ */
+export function parseURL(location: string): HistoryLocationNormalized {
+ let path = '',
+ query: HistoryQuery = {},
+ searchString = '',
+ hash = ''
+
+ // Could use URL and URLSearchParams but IE 11 doesn't support it
+ const searchPos = location.indexOf('?')
+ const hashPos = location.indexOf('#', searchPos > -1 ? searchPos : 0)
+ if (searchPos > -1) {
+ path = location.slice(0, searchPos)
+ searchString = location.slice(
+ searchPos + 1,
+ hashPos > -1 ? hashPos : location.length
+ )
+
+ query = parseQuery(searchString)
+ }
+
+ if (hashPos > -1) {
+ path = path || location.slice(0, hashPos)
+ hash = location.slice(hashPos, location.length)
+ }
+
+ path = path || location
+
+ return {
+ fullPath: location,
+ path,
+ query,
+ hash,
+ }
+}
+
+/**
+ * Transform a queryString into a query object. Accept both, a version with the leading `?` and without
+ * Should work as URLSearchParams
+ * @param search
+ */
+export function parseQuery(search: string): HistoryQuery {
+ // TODO: optimize by using a for loop
+ const hasLeadingIM = search[0] === '?'
+ return (hasLeadingIM ? search.slice(1) : search).split('&').reduce(
+ (query: HistoryQuery, entry: string) => {
+ const [key, value] = entry.split('=')
+ if (key in query) {
+ // an extra variable for ts types
+ let currentValue = query[key]
+ if (!Array.isArray(currentValue)) {
+ currentValue = query[key] = [currentValue]
+ }
+ currentValue.push(value)
+ } else {
+ query[key] = value
+ }
+
+ return query
+ },
+ {} as HistoryQuery
+ )
+}
+
+/**
+ * Stringify a URL object
+ * @param location
+ */
+export function stringifyURL(location: HistoryLocation): string {
+ let url = location.path
+ let query = location.query ? stringifyQuery(location.query) : ''
+
+ return url + (query.length && '?' + query) + (location.hash || '')
+}
+
+/**
+ * Stringify an object query. Works like URLSearchParams. Doesn't prepend a `?`
+ * @param query
+ */
+export function stringifyQuery(query: HistoryQuery): string {
+ let search = ''
+ // TODO: util function?
+ for (const key in query) {
+ if (search.length > 1) search += '&'
+ // TODO: handle array
+ const value = query[key]
+ if (Array.isArray(value)) {
+ search += `${key}=${value[0]}`
+ for (let i = 1; i < value.length; i++) {
+ search += `&${key}=${value[i]}`
+ }
+ } else {
+ search += `${key}=${query[key]}`
+ }
+ }
+
+ return search
+}
+
+/**
+ * Prepare a URI string to be passed to pushState
+ * @param uri
+ */
+export function prepareURI(uri: string) {
+ // encode the % symbol so it also works on IE
+ return uri.replace(PERCENT_RE, '%25')
+}
+
+// use regular decodeURI
+export const decodeURI = global.decodeURI
+
+/**
+ * Normalize a History location into an object that looks like
+ * the one at window.location
+ * @param location
+ */
+export function normalizeLocation(
+ location: string | HistoryLocation
+): HistoryLocationNormalized {
+ if (typeof location === 'string') return parseURL(location)
+ else
+ return {
+ fullPath: stringifyURL(location),
+ path: location.path,
+ query: location.query || {},
+ hash: location.hash || '',
+ }
+}
-import { BaseHistory } from './history/base'
+import { BaseHistory, HistoryLocationNormalized } from './history/base'
import { RouterMatcher } from './matcher'
import {
RouteLocation,
RouteRecord,
START_LOCATION_NORMALIZED,
RouteLocationNormalized,
- RouteQuery,
+ MatcherLocationNormalized,
} from './types/index'
interface RouterOptions {
this.history.listen((to, from, info) => {
// TODO: check navigation guards
- const url = this.history.parseURL(to)
- const matchedRoute = this.matcher.resolve(url, this.currentRoute)
- console.log({ url, matchedRoute })
+ const matchedRoute = this.matcher.resolve(to, this.currentRoute)
+ console.log({ to, matchedRoute })
// TODO: navigate
})
}
* @param to Where to go
*/
push(to: RouteLocation) {
- // TODO: resolve URL
- let url, fullPath: string, query: RouteQuery, hash: string
- if (typeof to === 'string') {
- url = this.history.parseURL(to)
- fullPath = url.fullPath
- query = url.query
- hash = url.hash
- } else if ('path' in to) {
- fullPath = this.history.stringifyURL(to)
- query = to.query || {}
- hash = to.hash || ''
- url = to
- } else if ('name' in to) {
- // we need to resolve first
- url = to
+ let url: HistoryLocationNormalized
+ let location: MatcherLocationNormalized
+ if (typeof to === 'string' || 'path' in to) {
+ url = this.history.utils.normalizeLocation(to)
+ location = this.matcher.resolve(url, this.currentRoute)
} else {
+ // named or relative route
// we need to resolve first
- url = to
+ location = this.matcher.resolve(to, this.currentRoute)
+ url = this.history.utils.normalizeLocation(location)
}
- console.log('going to', to)
- const location = this.matcher.resolve(url, this.currentRoute)
console.log(location)
- // @ts-ignore
- console.log({ fullPath, query, hash })
console.log('---')
// TODO: call hooks, guards
// TODO: navigate
// this.history.push(location.fullPath)
- // this.currentRoute = {
- // ...url,
- // ...location,
- // fullPath,
- // query,
- // hash,
- // }
+ this.currentRoute = {
+ ...url,
+ ...location,
+ }
}
getRouteRecord(location: RouteLocation) {}
| LocationAsRelative
// exposed to the user in a very consistant way
-export interface RouteLocationNormalized {
- path: string
+export interface RouteLocationNormalized
+ extends Required<RouteQueryAndHash & LocationAsRelative & LocationAsPath> {
fullPath: string
name: string | void
- params: RouteParams
- query: RouteQuery
- hash: string
}
// interface PropsTransformer {
import { RouteQuery } from '../types'
+// TODO: merge with existing function from history/base.ts and more to
+// history utils
export function stringifyQuery(query: RouteQuery | void): string {
if (!query) return ''