/* * MIT License * * Copyright (c) 2019 Rémi Van Keisbelck * * Permission is hereby granted, free of charge, to any person obtaining a copy * of this software and associated documentation files (the "Software"), to deal * in the Software without restriction, including without limitation the rights * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell * copies of the Software, and to permit persons to whom the Software is * furnished to do so, subject to the following conditions: * * The above copyright notice and this permission notice shall be included in all * copies or substantial portions of the Software. * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. * */ import { Program, ProgramInterop } from './Program'; import { List, Cmd, Dispatcher, Sub, Task, Ok, Result, just, Maybe, maybeOf, nothing } from 'tea-cup-fp'; import * as React from 'react'; import { ReactNode } from 'react'; /** * Props for the ProgramWithNav. */ export interface NavProps extends ProgramInterop { readonly onUrlChange: (l: Location) => Msg; readonly init: (l: Location) => [Model, Cmd]; readonly view: (dispatch: Dispatcher, m: Model) => ReactNode; readonly update: (msg: Msg, model: Model) => [Model, Cmd]; readonly subscriptions: (model: Model) => Sub; flushSyncDefault?: boolean; } export function ProgramWithNav(props: NavProps) { return ( props.init(window.location)} view={props.view} update={(msg, model) => { return props.update(msg, model); }} subscriptions={(m) => addNavSub(props.subscriptions(m), props.onUrlChange)} dispatchBridge={props.dispatchBridge} listener={props.listener} setModelBridge={props.setModelBridge} paused={props.paused} flushSyncDefault={props.flushSyncDefault} /> ); } function addNavSub(sub: Sub, toMsg: (l: Location) => M): Sub { return Sub.batch([sub, onPopState(toMsg)]); } export function onPopState(toMsg: (l: Location) => M): Sub { return new NavSub(toMsg); } class NavSub extends Sub { constructor(readonly toMsg: (l: Location) => M) { super(); } private l = () => { setTimeout(() => { const m = this.toMsg(window.location); this.dispatch(m); }); }; protected onInit(): void { super.onInit(); window.addEventListener('popstate', this.l); } protected onRelease(): void { super.onRelease(); window.removeEventListener('popstate', this.l); } } /** * Return a Task that will eventually change the browser location via historty.pushState, * and send back a Msg into the Program * @param url the url to navigate to */ export function newUrl(url: string): Task { return new NewUrlTask(url); } class NewUrlTask extends Task { readonly url: string; constructor(url: string) { super(); this.url = url; } execute(callback: (r: Result) => void): void { const state = {}; window.history.pushState(state, '', this.url); const popStateEvent = new PopStateEvent('popstate', { state: state }); dispatchEvent(popStateEvent); callback(new Ok(document.location)); } } // router // ------ export abstract class PathElem { abstract mapPart(part: string): Maybe; } class ConstantPathElem extends PathElem { readonly s: string; constructor(s: string) { super(); this.s = s; } mapPart(part: string): Maybe { if (part === this.s) { return just(part); } else { return nothing; } } } class RegexPathElem extends PathElem { private readonly regex: RegExp; private readonly converter: (s: string) => Maybe; constructor(regex: RegExp, converter: (s: string) => Maybe) { super(); this.regex = regex; this.converter = converter; } mapPart(part: string): Maybe { if (this.regex.test(part)) { return this.converter(part); } else { return nothing; } } } class IntPathElem extends RegexPathElem { constructor() { super(new RegExp('^\\d+$'), (s) => { try { const i = parseInt(s, 10); if (isNaN(i)) { return nothing; } else { return just(i); } } catch (e) { return nothing; } }); } static INSTANCE: IntPathElem = new IntPathElem(); } class StrPathElem extends PathElem { mapPart(part: string): Maybe { return just(part); } } export function str(s?: string): PathElem { if (s === undefined) { return new StrPathElem(); } else { return new ConstantPathElem(s); } } export function int(): PathElem { return IntPathElem.INSTANCE; } export function regex(r: RegExp, converter: (s: string) => Maybe): PathElem { return new RegexPathElem(r, converter); } export class Path0 { map(f: (query: QueryParams) => R): RouteDef { return new RouteDef([], f); } } export class QueryParams { private readonly store: { [id: string]: string[] }; private readonly hash: Maybe; private constructor(store: { [id: string]: string[] }, hash: Maybe) { this.store = store; this.hash = hash; } getValues(name: string): Maybe> { return maybeOf(this.store[name]); } getValue(name: string): Maybe { const values = this.store[name]; if (values === undefined) { return nothing; } else { return List.fromArray(values).head(); } } getHash(): Maybe { return this.hash; } static empty(): QueryParams { return new QueryParams({}, nothing); } static fromQueryStringAndHash(queryString?: string, hash?: string): QueryParams { const params = queryString === undefined ? [] : queryString.split('&'); const store: { [id: string]: string[] } = {}; function addToStore(name: string, value: string) { let values = store[name]; if (values === undefined) { values = [value]; store[name] = values; } else { values.push(value); } } params.forEach((param) => { const parts = param.split('='); if (parts.length === 1) { addToStore(parts[0], ''); } else if (parts.length > 1) { addToStore(parts[0], parts[1]); } }); return new QueryParams( store, hash === '' ? nothing : maybeOf(hash).map((h) => (h.startsWith('#') ? h.substring(1) : h)), ); } } export class Path1 { readonly e: PathElem; constructor(e: PathElem) { this.e = e; } map(f: (t: T, query: QueryParams) => R): RouteDef { return new RouteDef([this.e], f); } } export class Path2 { readonly e1: PathElem; readonly e2: PathElem; constructor(e1: PathElem, e2: PathElem) { this.e1 = e1; this.e2 = e2; } map(f: (t1: T1, t2: T2, query: QueryParams) => R): RouteDef { return new RouteDef([this.e1, this.e2], f); } } export class Path3 { readonly e1: PathElem; readonly e2: PathElem; readonly e3: PathElem; constructor(e1: PathElem, e2: PathElem, e3: PathElem) { this.e1 = e1; this.e2 = e2; this.e3 = e3; } map(f: (t1: T1, t2: T2, t3: T3, query: QueryParams) => R): RouteDef { return new RouteDef([this.e1, this.e2, this.e3], f); } } export const route0: Path0 = new Path0(); export function route1(e: PathElem): Path1 { return new Path1(e); } export function route2(e1: PathElem, e2: PathElem): Path2 { return new Path2(e1, e2); } export function route3(e1: PathElem, e2: PathElem, e3: PathElem): Path3 { return new Path3(e1, e2, e3); } export interface RouteBase { checkRoute(pathname: string, query: QueryParams): Maybe; } export class RouteDef implements RouteBase { readonly pathElems: ReadonlyArray>; readonly f: Function; constructor(pathElems: ReadonlyArray>, f: Function) { this.pathElems = pathElems; this.f = f; } static sanitizePath(path: string): string { const p1 = path.startsWith('/') ? path.substring(1) : path; return p1.endsWith('/') ? p1.substring(0, p1.length - 1) : p1; } static splitPath(path: string): ReadonlyArray { const p = RouteDef.sanitizePath(path); return p === '' ? [] : p.split('/').map(decodeURIComponent); } checkRoute(pathname: string, query: QueryParams): Maybe { // extract path parts from location and split const parts = RouteDef.splitPath(pathname); if (parts.length === this.pathElems.length) { // map every individual part, bail out if // something cannot be converted const mappedParts = []; for (let i = 0; i < parts.length; i++) { const part = parts[i]; const pe = this.pathElems[i]; const mapped = pe.mapPart(part); switch (mapped.type) { case 'Just': mappedParts.push(mapped.value); break; case 'Nothing': return nothing; } } // append query params to args mappedParts.push(query); // now we have mapped args, let's call the route's func return just(this.f.apply({}, mappedParts)); } else { return nothing; } } } export class Router { readonly routes: ReadonlyArray>; constructor(...routeDefs: RouteBase[]) { this.routes = routeDefs; } parse(pathname: string, query: QueryParams): Maybe { // try all routes one after the other for (let i = 0; i < this.routes.length; i++) { const d = this.routes[i]; const r = d.checkRoute(pathname, query); if (r.type === 'Just') { return r; } } return nothing; } parseLocation(location: Location): Maybe { return this.parse(location.pathname, QueryParams.fromQueryStringAndHash(location.search, location.hash)); } }