+++ /dev/null
-import * as utils from './utils'
-import { ListenerRemover } from '../types'
-
-export type HistoryQuery = Record<string, string | string[]>
-// TODO: is it reall worth allowing null to form queries like ?q&b&c
-// When parsing using URLSearchParams, `q&c=` yield an empty string for q and c
-export type RawHistoryQuery = Record<string, string | string[] | null>
-
-export interface HistoryLocation {
- // pathname section
- path: string
- // search string parsed
- query?: RawHistoryQuery
- // hash with the #
- hash?: string
-}
-export interface HistoryLocationNormalized extends Required<HistoryLocation> {
- // full path (like href)
- fullPath: string
- query: HistoryQuery
-}
-
-// pushState clones the state passed and do not accept everything
-// it doesn't accept symbols, nor functions as values. It also ignores Symbols as keys
-type HistoryStateValue =
- | string
- | number
- | boolean
- | null
- | HistoryState
- | HistoryStateArray
-export interface HistoryState {
- [x: number]: HistoryStateValue
- [x: string]: HistoryStateValue
-}
-interface HistoryStateArray extends Array<HistoryStateValue> {}
-// export type HistoryState = Record<string | number, string | number | boolean | undefined | null |
-
-export const START: HistoryLocationNormalized = {
- fullPath: '/',
- path: '/',
- query: {},
- hash: '',
-}
-
-export enum NavigationDirection {
- // NOTE: is it better to have strings?
- back = 'back',
- forward = 'forward',
-}
-
-export interface NavigationCallback {
- (
- to: HistoryLocationNormalized,
- from: HistoryLocationNormalized,
- info: { direction: NavigationDirection }
- ): void
-}
-
-// TODO: should BaseHistory be just an interface instead?
-
-export abstract class BaseHistory {
- // previousState: object
- location: HistoryLocationNormalized = START
- base: string = ''
- paused: boolean = false
- utils = utils
-
- /**
- * Sync source with a different location.
- * Adds an entry to the history
- * @param to URL to go to
- */
- abstract push(to: HistoryLocation, data?: any): void
-
- /**
- * Syncs source with a different location
- * Replaces current entry in the history
- * @param to URL to go to
- */
- abstract replace(to: HistoryLocation): void
-
- /**
- * Goes back in history log. Should trigger any listener added via
- * `listen`. If we are on the first entry, behaviour may change depending
- * on implementation
- * @param triggerListeners should default to true. If false, won't trigger listeners added via .listen()
- */
- abstract back(triggerListeners?: boolean): void
-
- /**
- * Goes forward in history log. Should trigger any listener added via
- * `listen`. If we are on the last entry, behaviour may change depending
- * on implementation
- * @param triggerListeners should default to true. If false, won't trigger listeners added via .listen()
- */
- abstract forward(triggerListeners?: boolean): void
-
- /**
- * Notifies back whenever the location changes due to user interactions
- * outside of the applicaiton. For example, going back/forward on a
- * web browser
- * @param callback callback to be called whenever the route changes
- * @returns
- */
- abstract listen(callback: NavigationCallback): ListenerRemover
-
- /**
- * ensure the current location matches the external source
- * For example, in HTML5 and hash history, that would be
- * location.pathname
- * TODO: is this necessary?
- */
- abstract ensureLocation(): void
-}
+++ /dev/null
-import consola from '../consola'
-import { BaseHistory, HistoryLocationNormalized, HistoryLocation } from './base'
-import { NavigationCallback, HistoryState, NavigationDirection } from './base'
-
-const cs = consola.withTag('hash')
-
-// TODO: implement the mock instead
-/* istanbul ignore next */
-// @ts-ignore otherwise fails after rollup replacement plugin
-if (process.env.NODE_ENV === 'test') cs.mockTypes(() => jest.fn())
-
-type HashChangeHandler = (this: Window, ev: HashChangeEvent) => any
-
-/**
- * TODO: currently, we cannot prevent a hashchange, we could pass a callback to restore previous navigation on the listener. But we will face the same problems as with HTML5: go(-n) can leads to unexpected directions. We could save a copy of the history and the state, pretty much polyfilling the state stack
- */
-
-interface PauseState {
- currentLocation: HistoryLocationNormalized
- // location we are going to after pausing
- to: HistoryLocationNormalized
-}
-
-export class HashHistory extends BaseHistory {
- // private history = window.history
- private _hashChangeHandler: HashChangeHandler
- private _listeners: NavigationCallback[] = []
- private _teardowns: Array<() => void> = []
-
- // TODO: should it be a stack? a Dict. Check if the popstate listener
- // can trigger twice
- private pauseState: PauseState | null = null
-
- constructor() {
- super()
- // const to = this.createCurrentLocation()
- // replace current url to ensure leading slash
- // this.history.replaceState(buildState(null, to, null), '', to.fullPath)
- // we cannot use window.location.hash because some browsers
- // predecode it
- this.location = this.utils.normalizeLocation(
- getFullPath(window.location.href)
- )
- this._hashChangeHandler = this.setupHashListener()
- }
-
- // TODO: is this necessary
- ensureLocation() {}
-
- replace(location: HistoryLocation) {
- const to = this.utils.normalizeLocation(location)
- // this.pauseListeners(to)
- const hashIndex = window.location.href.indexOf('#')
- // set it before to make sure we can skip the listener with a simple check
- this.location = to
- window.location.replace(
- window.location.href.slice(0, hashIndex < 0 ? 0 : hashIndex) +
- '#' +
- to.fullPath
- )
- }
-
- push(location: HistoryLocation, data?: HistoryState) {
- const to = this.utils.normalizeLocation(location)
- // set it before to make sure we can skip the listener with a simple check
- this.location = to
- window.location.hash = '#' + to.fullPath
- }
-
- back(triggerListeners: boolean = true) {
- // TODO: check if we can go back
- // const previvousLocation = this.history.state
- // .back as HistoryLocationNormalized
- if (!triggerListeners) this.pauseListeners(this.location)
- window.history.back()
- }
-
- forward(triggerListeners: boolean = true) {}
-
- listen(callback: NavigationCallback) {
- // settup the listener and prepare teardown callbacks
- this._listeners.push(callback)
-
- const teardown = () => {
- this._listeners.splice(this._listeners.indexOf(callback), 1)
- }
-
- this._teardowns.push(teardown)
- return teardown
- }
-
- /**
- * Remove all listeners attached to the history and cleanups the history
- * instance
- */
- destroy() {
- for (const teardown of this._teardowns) teardown()
- this._teardowns = []
- if (this._hashChangeHandler)
- window.removeEventListener('hashchange', this._hashChangeHandler)
- }
-
- /**
- * Setups the popstate event listener. It's important to setup only
- * one to ensure the same parameters are passed to every listener
- */
- private setupHashListener() {
- const handler: HashChangeHandler = ({ oldURL, newURL }) => {
- // TODO: assert oldURL === this.location.fullPath
- cs.info('hashchange fired', {
- location: this.location.fullPath,
- oldURL,
- newURL,
- })
-
- // TODO: handle go(-2) and go(2) (skipping entries)
-
- const from = this.location
-
- const targetTo = getFullPath(newURL)
-
- if (from.fullPath === targetTo) {
- cs.info('ignored because internal navigation')
- return
- }
- // we have the state from the old entry, not the current one being removed
- // TODO: correctly parse pathname
- // TODO: ensure newURL value is consistent
- // handle encoding
- const to = this.utils.normalizeLocation(targetTo)
- this.location = to
-
- if (
- this.pauseState &&
- this.pauseState.to &&
- this.pauseState.to.fullPath === to.fullPath
- ) {
- cs.info('Ignored beacuse paused')
- // reset pauseState
- this.pauseState = null
- return
- }
-
- // call all listeners
- const navigationInfo = {
- // TODO: should we do an unknown direction?
- direction: NavigationDirection.forward,
- }
- this._listeners.forEach(listener =>
- listener(this.location, from, navigationInfo)
- )
- }
-
- // settup the listener and prepare teardown callbacks
- window.addEventListener('hashchange', handler)
- return handler
- }
-
- private pauseListeners(to: HistoryLocationNormalized) {
- this.pauseState = {
- currentLocation: this.location,
- to,
- }
- }
-}
-
-function getFullPath(href: string): string {
- const hashIndex = href.indexOf('#')
- // if no hash is present, we normalize it to the version without the hash
- const fullPath = hashIndex < 0 ? '' : href.slice(hashIndex + 1)
-
- // ensure leading slash
- return fullPath.indexOf('/') < 0 ? '/' + fullPath : fullPath
-}
+++ /dev/null
-import {
- HistoryLocationNormalized,
- HistoryQuery,
- HistoryLocation,
- RawHistoryQuery,
-} 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 = normalizeQuery(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,
- }
-}
-
-function safeDecodeUriComponent(value: string): string {
- try {
- value = decodeURIComponent(value)
- } catch (err) {
- // TODO: handling only URIError?
- console.warn(
- `[vue-router] error decoding query "${value}". Keeping the original value.`
- )
- }
-
- return value
-}
-
-function safeEncodeUriComponent(value: string): string {
- try {
- value = encodeURIComponent(value)
- } catch (err) {
- // TODO: handling only URIError?
- console.warn(
- `[vue-router] error encoding query "${value}". Keeping the original value.`
- )
- }
-
- return value
-}
-
-/**
- * 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 {
- const hasLeadingIM = search[0] === '?'
- const query: HistoryQuery = {}
- // avoid creating an object with an empty key and empty value
- // because of split('&')
- if (search === '' || search === '?') return query
- const searchParams = (hasLeadingIM ? search.slice(1) : search).split('&')
- for (let i = 0; i < searchParams.length; ++i) {
- let [key, value] = searchParams[i].split('=')
- key = safeDecodeUriComponent(key)
- value = safeDecodeUriComponent(value)
- 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
-}
-
-/**
- * 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 && '?' + query) + (location.hash || '')
-}
-
-/**
- * Stringify an object query. Works like URLSearchParams. Doesn't prepend a `?`
- * @param query
- */
-export function stringifyQuery(query: RawHistoryQuery): string {
- let search = ''
- // TODO: util function?
- for (const key in query) {
- if (search.length > 1) search += '&'
- const value = query[key]
- if (value === null) {
- // TODO: should we just add the empty string value?
- search += key
- continue
- }
- let encodedKey = safeEncodeUriComponent(key)
- let values: string[] = Array.isArray(value) ? value : [value]
- values = values.map(safeEncodeUriComponent)
-
- search += `${encodedKey}=${values[0]}`
- for (let i = 1; i < values.length; i++) {
- search += `&${encodedKey}=${values[i]}`
- }
- }
-
- return search
-}
-
-export function normalizeQuery(query: RawHistoryQuery): HistoryQuery {
- // TODO: implem
- const normalizedQuery: HistoryQuery = {}
- for (const key in query) {
- const value = query[key]
- if (value === null) normalizedQuery[key] = ''
- else normalizedQuery[key] = value
- }
- return normalizedQuery
-}
-
-/**
- * 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
-// Use a renamed export instead of global.decodeURI
-// to support node and browser at the same time
-const originalDecodeURI = decodeURI
-export { originalDecodeURI as 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 ? normalizeQuery(location.query) : {},
- hash: location.hash || '',
- }
-}