TBR: reader/router WIP UI refactor

Cleans up the router code so that functions which map to route changes are
clearly associated with the routes definition in main.js. Route functions no
longer expect to make async/out of band calls and only handle rendering
coordination.

* Temporarily removes code to bind the Mercury app to Vanadium.

Change-Id: Id79c6c7e73bd8dc28d2cb836d4d2ff442aabbc56
diff --git a/web/Makefile b/web/Makefile
index 67092d7..381f0ab 100644
--- a/web/Makefile
+++ b/web/Makefile
@@ -57,6 +57,7 @@
 .PHONY:
 lint: node_modules
 	@dependency-check package.json --entry browser/main.js
+	@dependency-check package.json --entry browser/main.js --unused --no-dev
 	@jshint .
 
 .PHONY:
diff --git a/web/browser/components/files/render.js b/web/browser/components/files/render.js
index c45c2dc..9b0f350 100644
--- a/web/browser/components/files/render.js
+++ b/web/browser/components/files/render.js
@@ -2,7 +2,7 @@
 // Use of this source code is governed by a BSD-style
 // license that can be found in the LICENSE file.
 
-var anchor = require('../../router/anchor');
+var anchor = require('../router/anchor');
 var click = require('../../events/click');
 var css = require('./index.css');
 var format = require('format');
diff --git a/web/browser/components/header/index.js b/web/browser/components/header/index.js
index 5d05bb7..19369e2 100644
--- a/web/browser/components/header/index.js
+++ b/web/browser/components/header/index.js
@@ -5,7 +5,7 @@
 var h = require('mercury').h;
 var insert = require('insert-css');
 var css = require('./index.css');
-var anchor = require('../../router/anchor');
+var anchor = require('../router/anchor');
 
 module.exports = {
   render: render
diff --git a/web/browser/router/anchor.js b/web/browser/components/router/anchor.js
similarity index 83%
rename from web/browser/router/anchor.js
rename to web/browser/components/router/anchor.js
index df2170c..44a9e62 100644
--- a/web/browser/router/anchor.js
+++ b/web/browser/components/router/anchor.js
@@ -4,13 +4,15 @@
 
 var hg = require('mercury');
 var h = require('mercury').h;
-var options = { preventDefault: true };
 var href = require('./index').href;
-var hashbang = require('./hashbang');
+var url = require('url');
+var document = require('global/document');
+// var hashbang = require('./hashbang');
 
 // Create a handle for dom-delegation. This step treats the generated `handle`
 // the same as a mercury channel.
 var handle = hg.Delegator.allocateHandle(click);
+var options = { preventDefault: true };
 
 module.exports = anchor;
 
@@ -27,10 +29,6 @@
 // Clicking the generated link will fire the callbacks in the router and have
 // the application state update appropriately.
 function anchor(attributes, text) {
-  // Ensure that the href has the hashbang boilerplate, this makes ctrl+click
-  // open the right url for loading the app in the correct state.
-  attributes.href = hashbang(attributes.href);
-
   attributes['ev-click'] = hg.sendClick(handle, {
     href: attributes.href
   }, options);
@@ -43,5 +41,7 @@
 // Used as a mercury channel to update the current route using the exported
 // `router.href` observable.
 function click(data) {
-  href.set(data.href);
+  var current = String(document.location.href);
+  var destination = url.resolve(current, data.href);
+  href.set(destination);
 }
diff --git a/web/browser/router/hashbang.js b/web/browser/components/router/hashbang.js
similarity index 92%
rename from web/browser/router/hashbang.js
rename to web/browser/components/router/hashbang.js
index 0206c5f..f7a79b5 100644
--- a/web/browser/router/hashbang.js
+++ b/web/browser/components/router/hashbang.js
@@ -23,8 +23,5 @@
     href = '#!/' + href;
   }
 
-  // add leading slash
-  href = '/' + href;
-
   return href;
 }
diff --git a/web/browser/components/router/index.js b/web/browser/components/router/index.js
new file mode 100644
index 0000000..4755b97
--- /dev/null
+++ b/web/browser/components/router/index.js
@@ -0,0 +1,161 @@
+// Copyright 2015 The Vanadium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+var debug = require('debug')('reader:component:router');
+var document = require('global/document');
+var format = require('format');
+var hashbang = require('./hashbang');
+var hg = require('mercury');
+var parse = require('url').parse;
+var pathToRegexp = require('path-to-regexp');
+var qs = require('qs');
+var source = require('geval/source');
+var window = require('global/window');
+
+var href = hg.value('');
+
+module.exports = {
+  state: state,
+  render: render,
+  href: href
+};
+
+function state(map) {
+  debug('initializing: %o', map);
+
+  var atom = hg.state({
+    href: hg.value(''),
+    route: hg.struct({}),
+    routes: [],
+    channels: {
+      match: match
+    }
+  });
+
+  for (var key in map) { // jshint ignore: line
+    var keys = [];
+
+    atom.routes.push({
+      pattern: (key === '*') ? '(.*)' : key, // "*" should be greedy.
+      keys: keys,
+      re: pathToRegexp(key, keys),
+      fn: map[key]
+    });
+  }
+
+  // Treat changes to window.location as a user event which should trigger the
+  // match channel.
+  popstate(function onpopstate(href) {
+    match(atom, { href: href });
+  });
+
+  // Changes to atom.href should be reflected in the url bar.
+  atom.href(function onhref(href) {
+    window.history.pushState(null, document.title, href);
+  });
+
+  // Bind the shared href atom used in ./anchor to this component's match
+  // channel.
+  module.exports.href(function onhref(href) {
+    match(atom, { href: href });
+  });
+
+  // Fire the initial route on initialization.
+  debug('firing initial route');
+  match(atom, {
+    href: String(document.location.href)
+  });
+
+  return atom;
+}
+
+function render(state) {
+  // Safely map arguments without deoptimizing the render function.
+  // SEE: http://git.io/XTo7TQ
+  var length = arguments.length - 1;
+  var args = new Array(length);
+  for (var i = 0; i < length; i++) {
+    args[i] = arguments[i + 1];
+  }
+
+  var route = state.route;
+
+  // Append params and route to the end of the arguments and call the current
+  // route's render function.
+  args.push(route.params);
+  args.push(route);
+
+  return route.fn.apply(null, args);
+}
+
+function match(state, data) {
+  debug('channel: match %s', data.href);
+
+  if (state.href() === data.href) {
+    debug('no update to href, skipping');
+    return;
+  }
+
+  var routes = state.routes;
+  var url = parse(data.href);
+  var hash = (url.hash) ? url.hash : hashbang(url.pathname);
+
+  var href = require('url').format({
+    protocol: url.protocol,
+    host: url.host,
+    pathname: '/',
+    hash: hash
+  });
+
+  state.href.set(href);
+
+  var length = routes.length;
+  var _match;
+
+  for (var i = 0; i < length; i++) {
+    var route = routes[i];
+    _match = hash.match(route.re);
+
+    if (!_match) {
+      continue;
+    }
+
+    var result = {
+      route: route.pattern,
+      fn: route.fn,
+      query: qs.parse(url.query),
+      params: {}
+    };
+
+    var ki = route.keys.length;
+    while (ki--) {
+      var key = route.keys[ki].name;
+      var value = _match[ki+1];
+      result.params[key] = value;
+    }
+
+    state.route.set(result);
+
+    break;
+  }
+
+  if (! _match) {
+    var template = 'No route defined for "%s". To prevent this error add a' +
+      'route for "*" .';
+    throw new Error(format(template, data.href));
+  }
+}
+
+function popstate(listener) {
+  var event = source(lambda);
+
+  event(listener);
+
+  function lambda(broadcast) {
+    window.addEventListener('popstate', function(event) {
+      var href = String(document.location.href);
+      broadcast(href);
+    });
+  }
+}
diff --git a/web/browser/main.js b/web/browser/main.js
index 2240116..0c1c662 100644
--- a/web/browser/main.js
+++ b/web/browser/main.js
@@ -2,28 +2,20 @@
 // Use of this source code is governed by a BSD-style
 // license that can be found in the LICENSE file.
 
-var constellation = require('./components/constellation');
 var css = require('./components/base/index.css');
 var debug = require('debug')('reader:main');
 var document = require('global/document');
 var domready = require('domready');
-var each = require('./util').each;
-var eos = require('end-of-stream');
 var files = require('./components/files');
 var footer = require('./components/footer');
+var format = require('format');
 var h = require('mercury').h;
 var header = require('./components/header');
 var hg = require('mercury');
 var insert = require('insert-css');
-var pdf = require('./components/pdf');
 var mover = require('./components/mover');
-var removed = require('./util').removed;
-var router = require('./router');
-var routes = require('./routes');
-var vanadium = require('./vanadium');
+var router = require('./components/router');
 var window = require('global/window');
-var qs = require('qs');
-var url = require('url');
 
 // Expose globals for debugging.
 window.debug = require('debug');
@@ -37,157 +29,56 @@
   var state = hg.state({
     store: hg.value(null),
     files: files.state(),
-    pdf: pdf.state({}),
     mover: mover.state({}),
-    constellation: constellation.state(),
-    error: hg.value(null)
+    router: router.state({
+      '#!/': index,
+      '#!/mover': showMover,
+      '#!/:id': show,
+      '*': notfound
+    })
   });
 
-  // TODO(jasoncampbell): add an error component for aggregating, logging, and
-  // displaying errors in the UI.
-  state.error(function onstateerror(err) {
-    if (!err) {
-      return;
-    }
-
-    console.error('TODO(jasoncampbell): add an error component');
-    console.error(err.stack);
-
-    for (var key in err) {
-      if (err.hasOwnProperty(key)) {
-        console.error('* %s => %o', key, err[key]);
-      }
-    }
-  });
-
-  router(state, routes).on('notfound', notfound);
-
-  // Quick way to change the id of the running application using a query param.
-  // TODO(jasoncampbell): Create a configuration screen/component.
-  var query = url.parse(window.location.href).query;
-  var id = qs.parse(query).id || process.env.ID;
-
-  debug('##### %s #####', id);
-
-  // The vanadium client is coupled to the application state here so that async
-  // code paths in the ./vanadium modules can be isolated to the application
-  // initialization. This allows components to be separately tested/interacted
-  // with as mappings between data and UI without being tangled into the
-  // local vanadium discovery process.
-  var client = vanadium({ id: id });
-
-  client.on('error', function onvanadiumerror(err) {
-    state.error.set(err);
-  });
-
-  client.on('syncbase', function onsyncbase(store) {
-    state.store.set(store);
-
-    store.sync(function onsync(err) {
-      if (err) {
-        state.error.set(err);
-        return;
-      }
-
-      debug('store.sync succeeded!');
-    });
-
-    // Setup watch.
-    var ws = store.createWatchStream('files');
-
-    eos(ws, function(err) {
-      if (err) {
-        state.error.set(err);
-      }
-    });
-
-    ws.on('data', function onwatchchange(change) {
-      debug('watch stream change: %o', change);
-      // NOTE: this triggers a recursion between clients :(
-
-      if (change.type === 'put') {
-        state.files.collection.put(change.key, change.value);
-      }
-
-      if (change.type === 'delete') {
-        state.files.collection.delete(change.key);
-      }
-    });
-
-    // Scan all keys and populate state.
-    var stream = store.createReadStream('files');
-
-    stream.on('data', function onreadstreamdata(data) {
-      state.files.collection.put(data.key, data.value);
-    });
-
-    eos(stream, function(err) {
-      if (err) {
-        state.error.set(err);
-      }
-
-      // Add the watcher here, after the collection has been populated to
-      // prevent firing the listener and re-puting all the files again.
-      state.files.collection(fileschange);
-    });
-  });
-
-  function fileschange(collection) {
-    debug('state.files.collection => change: %o', collection);
-    var store = state.store();
-
-    // TODO(jasoncampbell): make sure to try and sync this at some point or
-    // block all UI until the runtime is ready.
-    // SEE: http://git.io/vn5YV
-    if (!store) {
-      return;
-    }
-
-    removed(collection, function(key) {
-      store.del('files', key, function(err) {
-        if (err) {
-          state.error.set(err);
-        }
-      });
-    });
-
-    each(collection, function(key, value) {
-      store.put('files', value, function callback(err, file) {
-        if (err) {
-          state.error.set(err);
-        }
-
-        state.files.collection[file.id].ref.set(file.ref);
-      });
-    });
-  }
-
   hg.app(document.body, state, render);
 });
 
 function render(state) {
+  debug('render: %o', state);
   insert(css);
 
   return h('.reader-app', [
     hg.partial(header.render, state),
-    hg.partial(content, state),
-    hg.partial(mover.render, state.mover, state.mover.channels),
+    hg.partial(router.render, state.router, state),
     hg.partial(footer.render, state, state.files.channels.add)
   ]);
 }
 
-function content(state) {
-  var partial;
-
-  if (state.hash) {
-    partial = hg.partial(pdf.render, state.pdf, state.pdf.channels);
-  } else {
-    partial = hg.partial(files.render, state.files, state.files.channels);
-  }
-
-  return h('main', [ partial ]);
+function index(state, params, route) {
+  return h('main', [
+    hg.partial(files.render, state.files, state.files.channels)
+  ]);
 }
 
-function notfound(href) {
+function show(state, params, route) {
+  debug('show: %s', params.id);
+
+  return h('main', [
+
+  ]);
+}
+
+function showMover(state, params, route) {
+  debug('show mover');
+  return h('main', [
+    hg.partial(mover.render, state.mover, state.mover.channels),
+  ]);
+}
+
+function notfound(state) {
+  var href = state.router.href;
   console.error('TODO: not found error - %s', href);
+
+  return h('.notfound', [
+    h('Not Found.'),
+    h('p', format('The page "%s" does not exisit.', state.router.href))
+  ]);
 }
diff --git a/web/browser/router/index.js b/web/browser/router/index.js
deleted file mode 100644
index fc9abb0..0000000
--- a/web/browser/router/index.js
+++ /dev/null
@@ -1,120 +0,0 @@
-// Copyright 2015 The Vanadium Authors. All rights reserved.
-// Use of this source code is governed by a BSD-style
-// license that can be found in the LICENSE file.
-
-var debug = require('debug')('router');
-var EE = require('events').EventEmitter;
-var inherits = require('util').inherits;
-var spa = require('single-page');
-var routes = require('routes');
-var hg = require('mercury');
-var hashbang = require('./hashbang');
-var window = require('global/window');
-
-// The block of code here creates an observable for the current href
-//
-// NOTE: The check for `window.location` allows tests to run in node. Since
-// this code is executing when `require(...)` is called on this module it will
-// be executed at startup, the check prevents errors with accessing undefined
-// properties on `window.location`.
-var pathname = window.location ? window.location.pathname : '';
-var current = hashbang(pathname);
-var atom = hg.value(current);
-
-module.exports = Router;
-module.exports.href = atom;
-
-// The single-page module does not natively support hashbang urls (only
-// window.location.pathname) so there are some work arounds to make sure
-// everything works as expected:
-//
-// * `window.addEventListener('hashchange', router);` - For back button support.
-// * String routes are prefixed with "/#!"
-// * The single-page callback, `router.update(href)` takes special care to
-// consider the hashbang prefix.
-
-// The routes module provides a simple way to define patterns that match to
-// functions.
-//
-// SEE: https://www.npmjs.com/package/routes
-function Router(state, hash) {
-  if (!(this instanceof Router)) {
-    return new Router(state, hash);
-  }
-
-  var router = this;
-
-  router.state = state;
-  router.routes = routes();
-
-  for (var key in hash) { // jshint ignore: line
-    router.routes.addRoute(key, hash[key]);
-  }
-
-  router.sp = spa(router.update.bind(router));
-
-  // SEE: Router.prototype.handleEvent
-  window.addEventListener('hashchange', router);
-
-  atom(function onhref(href) {
-    debug('atom update: %s', href);
-    href = hashbang(href);
-    router.sp.show(href);
-  });
-
-  EE.call(router);
-}
-
-inherits(Router, EE);
-
-// # router.update(href)
-//
-// Fired anytime the href is updated (via single-page). This method handles
-// href normalizing and route matching based on the hash of functions passed
-// in on initialization.
-Router.prototype.update = function(href) {
-  debug('dom href update: %s', href);
-
-  var router = this;
-  var hash = window.location.hash;
-
-  // force initial hashbang in case href is missing it.
-  if (href === '/' && ! href.match(hash)) {
-    debug('hash mismatch: %s', hash);
-    href += hash;
-  } else if (href === '/') {
-    // At this point it's possible that the `href` is missing the /#!/ and the
-    // original `window.location.hash` is empty. In this case update the
-    // `href` so that it will match '/#!/' and use `router.sp.push` to update
-    // the url without triggering any callbacks.
-    href = hashbang(href);
-    router.sp.push(href);
-  }
-
-  var route = router.routes.match(href);
-
-  if (route) {
-    route.fn.call(router, router.state, route.params, route);
-  } else {
-    router.emit('notfound', href);
-  }
-};
-
-// # router.handleEvent(event)
-//
-// Implments the `EventListener` API so that the "hashchange" event can be
-// listened to in order to enable the back button with hashbang urls.
-//
-// SEE: https://mdn.io/EventListener
-// SEE: https://mdn.io/WindowEventHandlers/onhashchange
-Router.prototype.handleEvent = function(event) {
-  if (event.type !== 'hashchange') {
-    return;
-  }
-
-  var router = this;
-  var hash = String(window.location.hash);
-  var current = hashbang(hash);
-
-  router.update(current);
-};
diff --git a/web/browser/routes.js b/web/browser/routes.js
deleted file mode 100644
index 9e2f05d..0000000
--- a/web/browser/routes.js
+++ /dev/null
@@ -1,26 +0,0 @@
-// Copyright 2015 The Vanadium Authors. All rights reserved.
-// Use of this source code is governed by a BSD-style
-// license that can be found in the LICENSE file.
-
-var debug = require('debug')('reader:routes');
-
-module.exports = {
-  '/#!/': index,
-  '/#!/:id': show,
-  // For testing purposes
-  '/#!/moving': overlay
-};
-
-function index(state, params, route) {
-  debug('index');
-}
-
-function show(state, params, route) {
-  debug('show: %o', params);
-}
-
-// TODO(jwnichols): Remove once the device management UI is integrated
-// into the UI.  Provides an entry point for testing.
-function overlay(state, params, route) {
-  state.mover.moving.set(true);
-}
diff --git a/web/package.json b/web/package.json
index 4212266..9c0c428 100644
--- a/web/package.json
+++ b/web/package.json
@@ -12,7 +12,9 @@
   },
   "license": "BSD",
   "devDependencies": {
+    "browserify": "^11.0.1",
     "concat-stream": "^1.5.0",
+    "dependency-check": "^2.5.1",
     "disc": "^1.3.2",
     "istanbul": "^0.3.17",
     "jshint": "^2.8.0",
@@ -29,38 +31,16 @@
     "envify": "~3.4.0"
   },
   "dependencies": {
-    "browserify": "^11.0.1",
     "cuid": "^1.3.8",
     "debug": "^2.2.0",
-    "deep-equal": "^1.0.1",
-    "dependency-check": "^2.5.1",
-    "dezalgo": "^1.0.3",
     "domready": "^1.0.8",
-    "end-of-stream": "^1.1.0",
-    "extend": "^2.0.1",
     "format": "~0.2.1",
+    "geval": "^2.1.1",
     "global": "^4.3.0",
-    "inherits": "^2.0.1",
     "insert-css": "^0.2.0",
-    "level-iterator-stream": "^1.3.0",
-    "level-js": "^2.2.1",
     "mercury": "^14.0.0",
-    "ms": "^0.7.1",
-    "once": "^1.3.2",
     "path-to-regexp": "^1.2.1",
-    "prr": "^1.0.1",
-    "qs": "^5.1.0",
-    "readable-blob-stream": "^1.0.0",
-    "routes": "^2.1.0",
-    "run-parallel": "^1.1.2",
-    "run-series": "^1.1.2",
-    "run-waterfall": "^1.1.2",
-    "sha256d": "^1.0.0",
-    "single-page": "^1.0.0",
-    "syncbase": "*",
-    "thunky": "^0.1.0",
-    "uuid": "^2.0.1",
-    "vanadium": "*",
+    "qs": "^5.2.0",
     "xtend": "^4.0.0"
   }
 }