@shaun/alpinejs-router
Version:
A lightweight client-side router for Alpine.js that adds declarative routes, link navigation, dynamic params, external templates, and hash or HTML5 history modes without turning your app into a larger framework.
479 lines (478 loc) • 15 kB
JavaScript
//#region src/url.js
var RouterURL = class {
#url;
#path;
constructor(url, opts = {}) {
this.#url = new URL(url);
this.mode = opts.mode ?? "web";
this.base = opts.base ?? "";
}
set url(val) {
this.#url = new URL(val);
this.#path = void 0;
}
get url() {
return this.#url.href;
}
get path() {
this.#path ??= this.#resolvePath();
return this.#path;
}
#hashContent() {
return this.#url.hash.slice(1).split("#").shift();
}
#resolvePath() {
if (this.mode === "hash" && this.#url.hash) return this.#hashContent().split("?").shift() || "/";
const pathname = this.#url.pathname;
const base = this.base && this.base !== "/" ? this.base.replace(/\/$/, "") : "";
if (!base) return pathname;
if (pathname === base) return "/";
if (pathname.startsWith(base + "/")) return pathname.slice(base.length) || "/";
return pathname;
}
get query() {
const search = this.mode === "hash" && this.#url.hash ? this.#hashContent().split("?").slice(1).join("?") : this.#url.search;
return Object.fromEntries(new URLSearchParams(search));
}
resolve(path, params, replace = false) {
const l = this.#url.origin + this.base + (this.mode === "hash" ? "#" : "") + path;
const r = replace ? new URLSearchParams(params).toString() : new URLSearchParams({
...this.query,
...params
}).toString();
this.url = l + (r ? "?" + r : "");
return this;
}
static resolveTemplatePath(pathname, tpl) {
if (!tpl) return tpl;
if (tpl.startsWith("/")) return tpl;
const dir = pathname.slice(0, pathname.lastIndexOf("/"));
if (tpl.startsWith("./")) return dir + tpl.slice(1);
if (tpl.startsWith("../")) {
const re = /\.\.\//g;
let i = tpl.match(re).length;
let arr = dir.split("/");
while (i--) arr.pop();
if (arr.length === 0) arr = [""];
return [...arr, tpl.replace(re, "")].join("/");
}
return tpl;
}
};
//#endregion
//#region src/pattern.js
var URLPattern = class {
static build(path) {
if (path.indexOf(":") === -1) return path;
const names = /* @__PURE__ */ new Set();
const pattern = path.split("/").map((e) => {
if (!e.startsWith(":")) return e;
let field = e.substr(1);
let fieldPattern = "[^/]+";
const open = field.indexOf("(");
if (open > -1) {
if (!field.endsWith(")")) throw new Error(`Malformed route parameter '${e}' in route '${path}'`);
fieldPattern = field.slice(open + 1, -1);
field = field.slice(0, open);
}
if (!/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(field)) throw new Error(`Invalid route parameter name ':${field}' in route '${path}'`);
if (names.has(field)) throw new Error(`Duplicate route parameter name ':${field}' in route '${path}'`);
names.add(field);
return `(?<${field}>${fieldPattern})`;
}).join("/");
try {
return new RegExp(`^${pattern}$`);
} catch (error) {
throw new Error(`Invalid route pattern '${path}': ${error.message}`);
}
}
static match(path, pattern) {
if (pattern instanceof RegExp) {
const found = path.match(pattern);
if (!found) return false;
return { ...found.groups };
}
return path === pattern;
}
static is(path, pattern) {
if (pattern instanceof RegExp) return pattern.test(path);
return path === pattern;
}
};
//#endregion
//#region src/router.js
var Router = class Router {
#patterns = {};
#staticRoutes = /* @__PURE__ */ new Set();
#dynamicRoutes = {};
#cache = {};
add(route) {
const pattern = this.#cache[route] ?? URLPattern.build(route);
this.#cache[route] = pattern;
this.#patterns[route] = pattern;
this.#indexRoute(route, pattern);
}
match(target) {
console.assert(target instanceof RouterURL);
const path = target.path;
if (this.#staticRoutes.has(path)) return {};
for (const routes of this.#getDynamicRoutes(path)) for (const [, pattern] of routes) {
const found = URLPattern.match(path, pattern);
if (found) return found === true ? {} : found;
}
return false;
}
is(target, ...routes) {
console.assert(target instanceof RouterURL);
const path = target.path;
for (const route of routes) {
const pattern = this.#patterns[route] ?? this.#cache[route] ?? URLPattern.build(route);
this.#cache[route] = pattern;
if (URLPattern.is(path, pattern)) return true;
}
return false;
}
not(target, ...routes) {
console.assert(target instanceof RouterURL);
const path = target.path;
for (const route of routes) {
const pattern = this.#patterns[route] ?? this.#cache[route] ?? URLPattern.build(route);
this.#cache[route] = pattern;
if (URLPattern.is(path, pattern)) return false;
}
return true;
}
notfound(target) {
console.assert(target instanceof RouterURL);
const path = target.path;
if (this.#staticRoutes.has(path)) return false;
for (const routes of this.#getDynamicRoutes(path)) for (const [, pattern] of routes) if (URLPattern.is(path, pattern)) return false;
return true;
}
#indexRoute(route, pattern) {
if (!(pattern instanceof RegExp)) {
this.#staticRoutes.add(route);
return;
}
const segments = Router.#segments(route);
const depth = segments.length;
const group = this.#dynamicRoutes[depth] ?? {
static: /* @__PURE__ */ new Map(),
wildcard: []
};
const first = segments[0];
const routes = first.startsWith(":") ? group.wildcard : group.static.get(first) ?? [];
const entry = [
route,
pattern,
Router.#scoreRoute(route)
];
const existingIndex = routes.findIndex(([current]) => current === route);
if (existingIndex > -1) routes.splice(existingIndex, 1);
let index = routes.findIndex((current) => Router.#compareRoutes(entry, current) < 0);
if (index === -1) index = routes.length;
routes.splice(index, 0, entry);
if (!first.startsWith(":")) group.static.set(first, routes);
this.#dynamicRoutes[depth] = group;
}
#getDynamicRoutes(path) {
const segments = Router.#segments(path);
const group = this.#dynamicRoutes[segments.length];
if (!group) return [];
const routes = [];
const first = segments[0];
const staticRoutes = group.static.get(first);
if (staticRoutes) routes.push(staticRoutes);
if (group.wildcard.length) routes.push(group.wildcard);
return routes;
}
static #compareRoutes([left, , leftScores], [right, , rightScores]) {
const length = Math.max(leftScores.length, rightScores.length);
for (let i = 0; i < length; i += 1) {
const diff = (rightScores[i] ?? 0) - (leftScores[i] ?? 0);
if (diff !== 0) return diff;
}
return right.length - left.length;
}
static #scoreRoute(route) {
return route.split("/").filter(Boolean).map((segment) => {
if (!segment.startsWith(":")) return 3;
return segment.includes("(") ? 2 : 1;
});
}
static #segments(path) {
return path.split("/").filter(Boolean);
}
};
//#endregion
//#region src/index.js
function isActivePath(targetPath, currentPath) {
if (targetPath === "/") return currentPath === "/";
return currentPath === targetPath || currentPath.startsWith(targetPath + "/");
}
function shouldHandleLinkClick(event, el, origin = location.origin) {
let href;
try {
href = new URL(el.href, origin);
} catch {
return false;
}
return !(event.defaultPrevented || event.button !== 0 || event.metaKey || event.altKey || event.ctrlKey || event.shiftKey || el.target && el.target !== "_self" || el.hasAttribute("download") || href.origin !== origin);
}
function getTemplateRenderMode({ hasInlineTemplate, templateUrl, hasCachedTemplate, preloadFailed }) {
if (!templateUrl) return hasInlineTemplate ? "inline" : "missing";
if (hasCachedTemplate) return "external";
if (preloadFailed && hasInlineTemplate) return "inline";
return "load";
}
function getRouteTemplateRoot(el, expression, reportError = console.error) {
const root = el.content.firstElementChild;
if (!root) return null;
if (root.nextElementSibling) {
reportError(`Route '${expression}' must render exactly one root element`);
return null;
}
return root;
}
function src_default(Alpine) {
const router = new Router();
let loadingCount = 0;
const state = Alpine.reactive({
mode: "web",
base: "",
href: location.href,
path: "",
query: {},
params: {},
loading: false
});
const route = {
get path() {
return state.path;
},
get query() {
return state.query;
},
get params() {
return state.params || {};
},
get loading() {
return state.loading;
},
config(config = {}) {
const base = config.mode !== "hash" && config.base && config.base.endsWith("/") ? config.base.slice(0, -1) : config.base;
state.mode = config.mode ?? "web";
state.base = base ?? "";
},
push(...args) {
return push(...args);
},
replace(...args) {
return replace(...args);
},
resolve(query = {}) {
return getFreshURL(state.href).resolve(state.path, query).url;
},
is(...paths) {
return router.is(getTargetURL(state.href), ...paths);
},
not(...paths) {
return router.not(getTargetURL(state.href), ...paths);
},
get notfound() {
return router.notfound(getTargetURL(state.href));
}
};
Alpine.$router = route;
Alpine.magic("router", () => route);
let cachedKey, cachedURL;
function getTargetURL(href) {
const key = `${state.mode}|${state.base}|${href}`;
if (key !== cachedKey) {
cachedKey = key;
cachedURL = getFreshURL(href);
}
return cachedURL;
}
function getFreshURL(href) {
return new RouterURL(href, {
mode: state.mode,
base: state.base
});
}
function parse() {
const url = getTargetURL(state.href);
state.path = url.path;
state.query = url.query;
state.params = router.match(url);
}
Alpine.effect(() => parse());
Alpine.nextTick(() => {
if (state.mode === "web" && !state.base) parse();
});
window.addEventListener("popstate", () => state.href = location.href);
window.addEventListener("hashchange", () => state.href = location.href);
function push(path, options = {}) {
let target = null;
try {
target = new URL(path);
} catch {
target = null;
}
if (target) {
if (target.origin !== location.origin) {
console.error(`Cannot push cross-origin URL '${path}'`);
return;
}
} else if (state.mode === "hash") path = location.origin + (state.base || "/") + "#" + path;
else path = location.origin + state.base + path;
if (location.href !== path) {
history[options.replace ? "replaceState" : "pushState"]({}, "", path);
state.href = path;
}
}
function replace(path) {
push(path, { replace: true });
}
function trackLoading(promise) {
loadingCount += 1;
state.loading = true;
return promise.finally(() => {
loadingCount = Math.max(loadingCount - 1, 0);
state.loading = loadingCount > 0;
});
}
function reportTemplateError(route, tpl, error) {
console.error(`Failed to load template '${tpl}' for route '${route}'`, error);
}
const templateCaches = Object.create(null);
const inLoadProgress = Object.create(null);
const inMakeProgress = /* @__PURE__ */ new WeakSet();
Alpine.directive("route", (el, { modifiers, expression }, { effect, cleanup }) => {
let invalidRoute = false;
if (!modifiers.includes("notfound")) try {
router.add(expression);
} catch (error) {
console.error(error.message);
invalidRoute = true;
}
const load = (url) => {
const render = (html) => {
el.innerHTML = html;
return html;
};
if (inLoadProgress[url]) return inLoadProgress[url].then(render);
inLoadProgress[url] = fetch(url).then((r) => {
if (!r.ok) throw new Error(`Failed to fetch template: ${r.status} ${r.statusText}`);
return r.text();
}).then((html) => {
templateCaches[url] = html;
return html;
}).finally(() => delete inLoadProgress[url]);
return inLoadProgress[url].then(render);
};
const tpl = RouterURL.resolveTemplatePath(location.pathname, el.getAttribute("template") ?? el.getAttribute("template.preload"));
let loading;
let preloadFailed = false;
if (el.hasAttribute("template.preload")) loading = trackLoading(load(tpl)).then(() => true).catch((error) => {
preloadFailed = true;
reportTemplateError(expression, tpl, error);
return false;
}).finally(() => {
loading = false;
});
function show() {
if (el._x_currentIfEl) return el._x_currentIfEl;
const make = () => {
if (inMakeProgress.has(el)) return;
const root = getRouteTemplateRoot(el, expression);
if (!root) return;
inMakeProgress.add(el);
const clone = root.cloneNode(true);
Alpine.addScopeToNode(clone, {}, el);
Alpine.mutateDom(() => {
el.after(clone);
Alpine.initTree(clone);
});
el._x_currentIfEl = clone;
el._x_undoIf = () => {
clone.remove();
delete el._x_currentIfEl;
};
Alpine.nextTick(() => inMakeProgress.delete(el));
};
const renderInline = () => {
if (el.content.firstElementChild) {
make();
return true;
}
return false;
};
const mode = getTemplateRenderMode({
hasInlineTemplate: Boolean(el.content.firstElementChild),
templateUrl: tpl,
hasCachedTemplate: tpl in templateCaches,
preloadFailed
});
if (mode === "inline") renderInline();
else if (mode === "external") {
if (tpl in templateCaches) {
el.innerHTML = templateCaches[tpl];
make();
}
} else if (mode === "load") {
if (loading) loading.then((loaded) => loaded ? make() : renderInline());
else trackLoading(load(tpl)).then(() => make()).catch((error) => {
reportTemplateError(expression, tpl, error);
if (!renderInline()) console.error(`Template for '${expression}' is missing`);
});
} else console.error(`Template for '${expression}' is missing`);
}
function hide() {
if (el._x_undoIf) {
el._x_undoIf();
delete el._x_undoIf;
}
}
Alpine.nextTick(() => {
effect(() => {
if (invalidRoute) {
hide();
return;
}
const target = getTargetURL(state.href);
(modifiers.includes("notfound") ? router.notfound(target) : router.is(target, expression)) ? show() : hide();
});
});
cleanup(() => el._x_undoIf && el._x_undoIf());
});
Alpine.directive("link", (el, { modifiers, expression }, { evaluate, effect, cleanup }) => {
const url = getFreshURL(el.href);
el.href = url.resolve(url.path, url.query, true).url;
function go(e) {
if (!shouldHandleLinkClick(e, el, location.origin)) return;
e.preventDefault();
push(el.href, { replace: modifiers.includes("replace") });
}
el.addEventListener("click", go);
if (modifiers.includes("activity")) {
const classes = expression ? evaluate(expression) : {};
classes.active ??= "active";
classes.exactActive ??= "exact-active";
const linkPath = url.path;
effect(() => {
const currentPath = getTargetURL(state.href).path;
el.classList.toggle(classes.active, isActivePath(linkPath, currentPath));
el.classList.toggle(classes.exactActive, linkPath === currentPath);
});
}
cleanup(() => {
el.removeEventListener("click", go);
});
});
}
//#endregion
//#region builds/module.js
var module_default = src_default;
//#endregion
export { module_default as default };