Skip to content

Instantly share code, notes, and snippets.

@klebba
Last active November 15, 2018 00:28
Show Gist options
  • Save klebba/fc5a75a72afa5544b25bb7e3ff3b14a2 to your computer and use it in GitHub Desktop.
Save klebba/fc5a75a72afa5544b25bb7e3ff3b14a2 to your computer and use it in GitHub Desktop.
A primitive pushState router
export default class Router {
static init(routes, wildcard) {
Object.defineProperty(this, 'routes', {
value: routes,
writable: false,
});
Object.defineProperty(this, 'patterns', {
value: this.build(routes),
writable: false,
});
Object.defineProperty(this, 'wildcard', {
value: wildcard,
writable: false,
});
window.addEventListener('popstate', this.onPopState.bind(this));
document.addEventListener('click', this.onClick.bind(this));
this.read();
}
static onPopState() {
this.read();
}
static onClick(evt) {
const path = evt.composedPath();
const link = path.find(el => el.nodeName === 'A');
if (
evt.shiftKey === false &&
evt.ctrlKey === false &&
evt.metaKey === false &&
this.processLink(link)
) {
evt.preventDefault();
evt.stopPropagation();
}
}
static read() {
const match = this.resolve(new URL(window.location));
if (match) {
this.processRoute(match.route, match.params);
} else {
if (this.wildcard instanceof Function) {
this.processRoute(this.wildcard, new Map());
} else {
throw new Error('No wildcard route defined.');
}
}
}
static build(routes) {
const pattern = /:[^\s/]+/g;
return Array.from(routes.keys()).reduce((result, route) => {
// extract param names from the routes
const params = (route.match(pattern) || []).reduce((list, current) => {
const token = current.slice(1);
if (list.has(token)) {
throw new Error(`Ambiguous parameter name ${token}`);
} else {
return list.add(token);
}
}, new Set());
// construct matching pattern
const test = route.replace(pattern, '([\\w-]+)');
const regex = new RegExp(`^${test}$`);
return result.set(route, { regex, params });
}, new Map());
}
/**
* Search for a route definition that matches the provided url
*/
static resolve(url) {
const patterns = Array.from(this.patterns.entries());
const matches = patterns.reduce((result, [route, pattern]) => {
const match = url.pathname.match(pattern.regex);
if (Array.isArray(match)) {
const paramNames = Array.from(pattern.params);
const paramValues = match.slice(1);
const params = paramValues.reduce((list, param, index) => {
return list.set(paramNames[index], param);
}, new Map());
result.set(route, params);
}
return result;
}, new Map());
// prioritize literal matches
let result;
for (const [route, params] of matches.entries()) {
if (!result || result.params.size > params.size) {
const callback = this.routes.get(route);
result = { route: callback, params };
}
}
return result;
}
/**
* Takes an <a> element as input and determines if it should change the location
*/
static processLink(link) {
let processed = false;
if (link instanceof HTMLElement && link.href && !link.target) {
const from = new URL(window.location);
const dest = new URL(link);
// only inspect same-domain links
if (from.origin === dest.origin) {
const match = this.resolve(dest);
if (match) {
// publish new location
window.history.pushState({}, null, dest);
this.processRoute(match.route, match.params);
processed = true;
}
}
}
return processed;
}
static processRoute(route, params) {
if (route instanceof Function) {
const searchParams = new URLSearchParams(window.location.search);
route(params, searchParams, window.location.hash);
}
}
}
@klebba
Copy link
Author

klebba commented Jan 16, 2018

As of this writing Firefox does not support event.composedPath() -- here's a Polyfill:

  // Polyfill evt.composedPath() for Firefox
  function composedPath(evt) {
    if (evt.path) {
      return evt.path;
    }
    const path = [];
    let target = evt.target;
    while (target.parentNode) {
      path.push(target);
      target = target.parentNode;
    }
    path.push(document, window);
    return path;
  }

@klebba
Copy link
Author

klebba commented Feb 23, 2018

import Router from 'x-router.js';

const routes = new Map();
const title = 'My Application';

routes.set('/', () => {
  document.title = title;
});

routes.set('/page1', () => {
  document.title = `Page 1 \u2022 ${title}`;
});

routes.set('/page2', () => {
  document.title = `Page 2 \u2022 ${title}`;
});

routes.set('/page3/:bar/baz/:bazinga', (params) => {
  document.title = `Page 3 (${params.get('bazinga')}) \u2022 ${title}`;
});

Router.init(routes, () => { console.error('No matching route.') });

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment