320 lines
8.2 KiB
JavaScript
320 lines
8.2 KiB
JavaScript
"use strict";
|
|
|
|
// modified page.js by visionmedia
|
|
// - changed regexes to components
|
|
// - removed unused crap
|
|
// - refactored to classes
|
|
// - simplified method chains
|
|
// - added ability to call .save() in .exit() without side effects
|
|
// - page refresh recovers state from history
|
|
// - rename .save() to .replaceState()
|
|
// - offer .url
|
|
|
|
const clickEvent = document.ontouchstart ? "touchstart" : "click";
|
|
const uri = require("./util/uri.js");
|
|
let location = window.history.location || window.location;
|
|
|
|
function _getOrigin() {
|
|
return (
|
|
location.protocol +
|
|
"//" +
|
|
location.hostname +
|
|
(location.port ? ":" + location.port : "")
|
|
);
|
|
}
|
|
|
|
function _isSameOrigin(href) {
|
|
return href && href.indexOf(_getOrigin()) === 0;
|
|
}
|
|
|
|
function _getBaseHref() {
|
|
const bases = document.getElementsByTagName("base");
|
|
return bases.length > 0
|
|
? bases[0].href.replace(_getOrigin(), "").replace(/\/+$/, "")
|
|
: "";
|
|
}
|
|
|
|
class Context {
|
|
constructor(path, state) {
|
|
const base = _getBaseHref();
|
|
path = path.indexOf("/") !== 0 ? "/" + path : path;
|
|
path = path.indexOf(base) !== 0 ? base + path : path;
|
|
|
|
this.canonicalPath = path;
|
|
this.path = !path.indexOf(base) ? path.slice(base.length) : path;
|
|
|
|
this.title = document.title;
|
|
this.state = state || {};
|
|
this.state.path = path;
|
|
this.parameters = {};
|
|
}
|
|
|
|
pushState() {
|
|
history.pushState(this.state, this.title, this.canonicalPath);
|
|
}
|
|
|
|
replaceState() {
|
|
history.replaceState(this.state, this.title, this.canonicalPath);
|
|
}
|
|
}
|
|
|
|
class Route {
|
|
constructor(path) {
|
|
this.method = "GET";
|
|
this.path = path;
|
|
|
|
this.parameterNames = [];
|
|
if (this.path === null) {
|
|
this.regex = /.*/;
|
|
} else {
|
|
let parts = [];
|
|
for (let component of this.path) {
|
|
if (component[0] === ":") {
|
|
parts.push("([^/]+)");
|
|
this.parameterNames.push(component.substr(1));
|
|
} else {
|
|
// assert [a-z]+
|
|
parts.push(component);
|
|
}
|
|
}
|
|
let regexString = "^/" + parts.join("/");
|
|
regexString += "(?:/*|/((?:(?:[a-z]+=[^/]+);)*(?:[a-z]+=[^/]+)))$";
|
|
this.parameterNames.push("variable");
|
|
this.regex = new RegExp(regexString);
|
|
}
|
|
}
|
|
|
|
middleware(fn) {
|
|
return (ctx, next) => {
|
|
if (this.match(ctx.path, ctx.parameters)) {
|
|
return fn(ctx, next);
|
|
}
|
|
next();
|
|
};
|
|
}
|
|
|
|
match(path, parameters) {
|
|
const qsIndex = path.indexOf("?");
|
|
const pathname = ~qsIndex ? path.slice(0, qsIndex) : path;
|
|
const match = this.regex.exec(pathname);
|
|
|
|
if (!match) {
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
for (let i = 1; i < match.length; i++) {
|
|
const name = this.parameterNames[i - 1];
|
|
const value = match[i];
|
|
if (value === undefined) {
|
|
continue;
|
|
}
|
|
|
|
if (name === "variable") {
|
|
for (let word of (value || "").split(/;/)) {
|
|
const [key, subvalue] = word.split(/=/, 2);
|
|
parameters[key] = uri.unescapeParam(subvalue);
|
|
}
|
|
} else {
|
|
parameters[name] = uri.unescapeParam(value);
|
|
}
|
|
}
|
|
} catch (e) {
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
}
|
|
|
|
class Router {
|
|
constructor() {
|
|
this._callbacks = [];
|
|
this._exits = [];
|
|
}
|
|
|
|
enter(path) {
|
|
const route = new Route(path);
|
|
for (let i = 1; i < arguments.length; ++i) {
|
|
this._callbacks.push(route.middleware(arguments[i]));
|
|
}
|
|
}
|
|
|
|
exit(path, fn) {
|
|
const route = new Route(path);
|
|
for (let i = 1; i < arguments.length; ++i) {
|
|
this._exits.push(route.middleware(arguments[i]));
|
|
}
|
|
}
|
|
|
|
start() {
|
|
if (this._running) {
|
|
return;
|
|
}
|
|
this._running = true;
|
|
this._onPopState = _onPopState(this);
|
|
this._onClick = _onClick(this);
|
|
window.addEventListener("popstate", this._onPopState, false);
|
|
document.addEventListener(clickEvent, this._onClick, false);
|
|
const url = location.pathname + location.search + location.hash;
|
|
return this.replace(url, history.state, true);
|
|
}
|
|
|
|
stop() {
|
|
if (!this._running) {
|
|
return;
|
|
}
|
|
this._running = false;
|
|
document.removeEventListener(clickEvent, this._onClick, false);
|
|
window.removeEventListener("popstate", this._onPopState, false);
|
|
}
|
|
|
|
showNoDispatch(path, state) {
|
|
const ctx = new Context(path, state);
|
|
ctx.pushState();
|
|
this.ctx = ctx;
|
|
return ctx;
|
|
}
|
|
|
|
show(path, state, push) {
|
|
const ctx = new Context(path, state);
|
|
const oldPath = this.ctx ? this.ctx.path : ctx.path;
|
|
this.dispatch(ctx, () => {
|
|
if (ctx.path !== oldPath && push !== false) {
|
|
ctx.pushState();
|
|
}
|
|
});
|
|
return ctx;
|
|
}
|
|
|
|
replace(path, state, dispatch) {
|
|
var ctx = new Context(path, state);
|
|
if (dispatch) {
|
|
this.dispatch(ctx, () => {
|
|
ctx.replaceState();
|
|
});
|
|
} else {
|
|
ctx.replaceState();
|
|
}
|
|
return ctx;
|
|
}
|
|
|
|
dispatch(ctx, middle) {
|
|
const swap = (_ctx, next) => {
|
|
this.ctx = ctx;
|
|
middle();
|
|
next();
|
|
};
|
|
const callChain = (this.ctx ? this._exits : []).concat(
|
|
[swap],
|
|
this._callbacks,
|
|
[this._unhandled, (ctx, next) => {}]
|
|
);
|
|
|
|
let i = 0;
|
|
let fn = () => {
|
|
callChain[i++](this.ctx, fn);
|
|
};
|
|
fn();
|
|
}
|
|
|
|
_unhandled(ctx, next) {
|
|
let current = location.pathname + location.search;
|
|
if (current === ctx.canonicalPath) {
|
|
return;
|
|
}
|
|
this.stop();
|
|
location.href = ctx.canonicalPath;
|
|
}
|
|
|
|
get url() {
|
|
return location.pathname + location.search + location.hash;
|
|
}
|
|
}
|
|
|
|
const _onPopState = (router) => {
|
|
let loaded = false;
|
|
if (document.readyState === "complete") {
|
|
loaded = true;
|
|
} else {
|
|
window.addEventListener("load", () => {
|
|
setTimeout(() => {
|
|
loaded = true;
|
|
}, 0);
|
|
});
|
|
}
|
|
return (e) => {
|
|
if (!loaded) {
|
|
return;
|
|
}
|
|
if (e.state) {
|
|
const path = e.state.path;
|
|
router.replace(path, e.state, true);
|
|
} else {
|
|
router.show(location.pathname + location.hash, undefined, false);
|
|
}
|
|
};
|
|
};
|
|
|
|
const _onClick = (router) => {
|
|
return (e) => {
|
|
if (1 !== _which(e)) {
|
|
return;
|
|
}
|
|
if (e.metaKey || e.ctrlKey || e.shiftKey) {
|
|
return;
|
|
}
|
|
if (e.defaultPrevented) {
|
|
return;
|
|
}
|
|
|
|
let el = e.path ? e.path[0] : e.target;
|
|
while (el && el.nodeName !== "A") {
|
|
el = el.parentNode;
|
|
}
|
|
if (!el || el.nodeName !== "A") {
|
|
return;
|
|
}
|
|
|
|
if (
|
|
el.hasAttribute("download") ||
|
|
el.getAttribute("rel") === "external"
|
|
) {
|
|
return;
|
|
}
|
|
|
|
const link = el.getAttribute("href");
|
|
if (el.pathname === location.pathname && (el.hash || "#" === link)) {
|
|
return;
|
|
}
|
|
if (link && link.indexOf("mailto:") > -1) {
|
|
return;
|
|
}
|
|
if (el.target) {
|
|
return;
|
|
}
|
|
if (!_isSameOrigin(el.href)) {
|
|
return;
|
|
}
|
|
|
|
const base = _getBaseHref();
|
|
const orig = el.pathname + el.search + (el.hash || "");
|
|
const path = !orig.indexOf(base) ? orig.slice(base.length) : orig;
|
|
|
|
if (base && orig === path) {
|
|
return;
|
|
}
|
|
|
|
e.preventDefault();
|
|
router.show(orig);
|
|
};
|
|
};
|
|
|
|
function _which(e) {
|
|
e = e || window.event;
|
|
return e.which === null ? e.button : e.which;
|
|
}
|
|
|
|
Router.prototype.Context = Context;
|
|
Router.prototype.Route = Route;
|
|
module.exports = new Router();
|