Home Reference Source

src/app/router.js

'use strict'

import { eachEntry } from '../core/util.js'
import Page from '../view/page.js'

/**** Design memo *********************************************

* Open `http://FQDN/` and Enter in URL bar --> onload
* Input appending `http://FQDN/#!/` and Enter in URL bar --> onhashchange
* Open `http://FQDN/#!/xxxx` in URL bar --> onload
* Reload --> onload
* Click a link with href=`#!/xxxx` --> onhashchange
* Run script `window.location.hash = '#!/xxx';` --> onhashchange
* Back to `#!/xxxx' --> onhashchange
* Forward to `#!/xxxx' --> onhashchange

```
const routeMap = {
  index: HomeView,                  // /
  about: AboutView,                 // /about
  books: {
    index: BookListView,            // /books
    ':bookId': {
      index:   BookView,            // /books/:bookId
      summary: BookSummaryView,     // /books/:bookId/summary
      readers: {
        index: '../',               // redirect to /books/:bookId
        ':readerId': BookReaderView // /books/:bookId/readers/:readerId
      }
    }
  },
  docs: {
    index: DocIndexView,            // /docs
    api:   DocApiView,              // /docs/api
  },
  ':userId': {
    index: UserView,                // /:userId
    timeline: UserTimelineView      // /:userId/timeline
  }
}
```

be converted to

```
const _routeTree = {
  index: HomeView,
  about: AboutView,
  books: {
    index: BookListView,
    _any_: {
      id: 'bookId',
      children: {
        index: BookView,
        summary: BookSummaryView,
        readers: {
          index: '../',
          _any_: {
            view: BookReaderView,
            id:   'readerId'
          }
        }
      }
    }
  },
  docs: {
    index: DocIndexView,
    api: DocApiView
  },
  _any_: {
    id: 'userId',
    children: {
      index: UserView,
      timeline: UserTimelineView
    }
  }
}
```
***********************************************************/

/**
 * Router
 *
 * This subclass must override depart() and go(path)
 *
 * @access private
 * @example
 * const routeMap = {
 *   index: HomeView,                  // /
 *   about: AboutView,                 // /about
 *   books: {
 *     index: BookListView,            // /books/
 *     ':bookId': {
 *       index: BookDetailView,        // /books/:bookId
 *       summary: BookSummaryView,     // /books/:bookId/summary
 *       readers: {
 *         index: '../',   // redirect to /books/:bookId
 *         ':readerId': BookReaderView // /books/:bookId/readers/:readerId
 *       }
 *     },
 *     pages: {
 *       index: PagesView,             // /pages/
 *       faq: FaqView,                 // /pages/faq
 *       policy: PolicyView            // /pages/policy
 *     }
 * };
 *
 * const router = new Router(routeMap);
 */
class Router {
  constructor(routeMap, onMove) {
    this._routeTree = this._parseRoute(routeMap);
    this._lastRoute = null;
    this.onMove = onMove;
  }

  /**
   * Need call `depart` after first page loaded
   *
  depart() {
  } */

  canGo(path) {
    return this.getRoute(path) !== null;
  }

  /**
   * Need call `go` when History mode and anchor tag clicked
   * or moving the other page by a script
   *
   * @param  {String} path path
  go(path) {
  } */

  move(path) {
    this.onMove(this.getRoute(path));
  }

  /**
   * @return {Object} layer
   * @property {Page} view - page view.
   * @property {Object} ctx - context (path ids).
   */
  getRoute(absPath) {
    if (!absPath.startsWith('/')) absPath = '/'+absPath;

    let pt = absPath;
    if (pt === '/') pt += 'index';
    pt = this._chopEndSlash(pt);
    if (pt.endsWith('.html')) pt = pt.substr(0, pt.length - 5);

    const paths = this._parsePath(pt);
    let tree = this._routeTree;

    let idMap = {}, key, route = null;
    while ((key = paths.shift()) !== undefined) {
      let target;
      if (key in tree) {
        target = tree[key];
      } else if ('_any_' in tree) {
        idMap[tree._any_.id] = key;
        tree = tree._any_.children;
        if (paths.length > 0) continue;
        target = tree.index;
      } else {
        alert(`${pt} page isn't defined`);
        throw '/';
      }

      if (this._isStr(target)) {
        const redirectPath = this._resolve(absPath, target);
        window.history.replaceState(null, null, redirectPath);
        return this.getRoute(redirectPath);
      } else if (target.prototype instanceof Page) {
        route = { view: target, ctx: idMap };
      } else {
        tree = target;        
        if (paths.length === 0) paths.unshift('index');
      }
    }

    if (route) return route;

    alert(`${absPath} page not found`);
    throw '/';
  }

  _parsePath(path) {
    const paths = path.split('/');
    paths.shift();
    return paths;
  }

  _parseRoute(map) {
    const tree = {};

    eachEntry(map, ([key, item]) => {
      const isViewOrStr = (item.prototype instanceof Page) || this._isStr(item);

      if (key.startsWith(':')) {
        const leaf = tree._any_ = { id: key.substr(1) }; // strip prefix :
        leaf.children = isViewOrStr ? { index: item } : this._parseRoute(item);
      } else {
        tree[key] = isViewOrStr ? item : this._parseRoute(item);
      }
    });

    return tree;
  }

  _resolve(src, dest) {
    if (dest.startsWith('/')) return dest;

    const parts = src.split('/');

    let md;
    while (md = dest.match(/^\.\.\/?(.*)$/)) {
      parts.pop();
      dest = md[1];
    }

    return parts.join('/') + dest;
  }

  _chopEndSlash(pt) {
    return pt.endsWith('/') ? pt.substr(0, pt.length - 1) : pt;
  }

  _isStr(v) {
    return typeof v === 'string'
  }
}

/**
 * @access private
 */
export class HistoryRouter extends Router {
  constructor(routeMap, onMove, rootPath) {
    super(routeMap, onMove);

    let pt = rootPath;
    if (!pt) {
      let base = document.querySelector('base');
      if (base) {
        const { pathname } = new URL(base.href);
        pt = this._chopEndSlash(pathname);
      }
    }
    this.basePath = pt || '';
  }

  depart() {
    document.addEventListener('click', e => {
      this._captureClick(e);
    }, true);

    const doMove = pt => {
      try {
        this.move(pt);
      } catch(e) {
        if (this._isStr(e)) {
          window.location.href = this.basePath + e;
        } else {
          console.error(e)
        }          
      }
    }

    window.onpopstate = e => {
      doMove(e.state ? e.state.path : '/')
    }

    let path = window.location.pathname.substr(this.basePath.length);
    if (path.length > 1 && path.endsWith('/')) {
      // Force strip last with / from path
      path = path.substr(0, path.length - 1);
      window.history.replaceState(null, null, path);
    }

    doMove(path);
  }

  go(path) {
    const h = window.history;
    if (h && h.pushState) {
      h.pushState({ path }, null, this.basePath + path);
      this.move(path);
    }
  }

  _captureClick(e) {
    const nxt = document.activeElement;
    if (!nxt || nxt.tagName !== 'A') return;
    if (nxt.target === '_top') return;

    if (nxt.href.startsWith(window.location.origin + this.basePath)) {
      e.preventDefault();

      try {
        this.go(nxt.pathname.substr(this.basePath.length));
      } catch(e) {
        if (this._isStr(e)) {
          this.go(e);
        } else {
          console.error(e)
        }
      }
    }
  }
}

/**
 * @access private
 */
export class HashRouter extends Router {
  constructor(routeMap, onMove, pathHead = '#!') {
    super(routeMap, onMove);
    this.head = pathHead;
  }

  get curPath() {
    return window.location.hash.substr(this.head.length)
  }

  depart() {
    const doMove = pt => {
      try {
        this.move(pt);
      } catch(e) {
        if (this._isStr(e)) {
          window.location.hash = this.head + e;
        } else {
          console.error(e)
        }
      }
    }

    window.onhashchange = e => {
      doMove(this.curPath || '/')
    }

    doMove(this.curPath);
  }

  go(path) {
    window.location.hash = this.head + path;
  }
}