UNPKG

@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
//#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 };