UNPKG

rvx

Version:

A signal based rendering library

364 lines (352 loc) 8.93 kB
/*! This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see <https://www.gnu.org/licenses/>. */ import { get, ENV, $, teardown, batch, Context, nest, watch } from './rvx.js'; function normalize(path, preserveDir = true) { if (path === "/" || path === "") { return ""; } if (!preserveDir && path.endsWith("/")) { path = path.slice(0, path.length - 1); } if (path.startsWith("/")) { return path; } return "/" + path; } function join(parent, child, preserveDir = true) { child = normalize(child, preserveDir); parent = normalize(parent, child === "" ? preserveDir : false); return parent + child; } function relative(from, to, preserveDir = true) { const base = normalize(from, false); to = normalize(to, preserveDir); if (base.length === 0) { return to; } let basePos = 0; for (;;) { const sep = base.indexOf("/", basePos + 1); const end = sep < 0 ? base.length : sep; const part = base.slice(basePos, end); if (to === part || (to.startsWith(part, basePos) && to[basePos + part.length] === "/")) { basePos = end; } else { break; } if (sep < 0) { break; } } let back = 0; for (let i = basePos; i < base.length; i++) { if (base[i] === "/") { back++; } } to = to.slice(basePos); if (back === 0 && to === "/") { return ""; } return "/..".repeat(back) + to; } class ChildRouter { #parent; #mountPath; #path; constructor(parent, mountPath, path) { this.#parent = parent; this.#mountPath = mountPath; this.#path = path; } get root() { return this.#parent.root; } get parent() { return this.#parent; } get path() { return get(this.#path); } get query() { return this.#parent.query; } push(path, query) { this.#parent.push(join(this.#mountPath, path), query); } replace(path, query) { this.#parent.replace(join(this.#mountPath, path), query); } } class Query { #raw; #params; constructor(raw, params) { this.#raw = raw; this.#params = params; } static from(init) { if (init === undefined) { return undefined; } if (typeof init === "string") { return new Query(init); } const params = new URLSearchParams(init); return new Query(params.toString(), params); } get raw() { return this.#raw; } get params() { if (this.#params === undefined) { this.#params = new URLSearchParams(this.#raw); } return this.#params; } } function formatQuery(value) { return typeof value === "string" ? value : new URLSearchParams(value).toString(); } class HashRouter { #env = ENV.current; #path = $(undefined); #query = $(undefined); constructor(options) { const env = this.#env; const parseEvents = options?.parseEvents ?? ["hashchange"]; const parse = this.parse.bind(this); for (const name of parseEvents) { env.window.addEventListener(name, parse, { passive: true }); teardown(() => env.window.removeEventListener(name, parse)); } this.parse(); } parse() { batch(() => { const hash = this.#env.location.hash.slice(1); const queryStart = hash.indexOf("?"); if (queryStart < 0) { this.#path.value = normalize(hash); this.#query.value = undefined; } else { this.#path.value = normalize(hash.slice(0, queryStart)); this.#query.value = new Query(hash.slice(queryStart + 1)); } }); } ; get root() { return this; } get parent() { return undefined; } get path() { return this.#path.value; } get query() { return this.#query.value; } push(path, query) { this.#env.location.hash = `#${normalize(path)}${query === undefined ? "" : `?${typeof query === "string" ? query : new URLSearchParams(query)}`}`; } replace(path, query) { this.push(path, query); } } class HistoryRouter { #env = ENV.current; #basePath; #path = $(undefined); #query = $(undefined); constructor(options) { const env = this.#env; this.#basePath = options?.basePath ?? ""; const parseEvents = options?.parseEvents ?? ["popstate", "rvx:router:update"]; const parse = this.parse.bind(this); for (const name of parseEvents) { env.window.addEventListener(name, parse, { passive: true }); teardown(() => env.window.removeEventListener(name, parse)); } this.parse(); } parse() { batch(() => { const env = this.#env; this.#path.value = relative(this.#basePath, env.location.pathname); this.#query.value = env.location.search.length > 0 ? new Query(env.location.search.slice(1)) : undefined; }); } ; #format(path, query) { let href = join(this.#basePath, path) || "/"; if (query !== undefined) { href += "?" + formatQuery(query); } return href; } get root() { return this; } get parent() { return undefined; } get path() { return this.#path.value; } get query() { return this.#query.value; } push(path, query) { const env = this.#env; env.history.pushState(null, "", this.#format(path, query)); env.window.dispatchEvent(new env.CustomEvent("rvx:router:update")); } replace(path, query) { const env = this.#env; env.history.replaceState(null, "", this.#format(path, query)); env.window.dispatchEvent(new env.CustomEvent("rvx:router:update")); } } class MemoryRouter { #parent; #path = $(undefined); #query = $(undefined); constructor(options) { this.#parent = options?.parent; this.#path.value = normalize(options?.path ?? ""); this.#query.value = Query.from(options?.query); } get root() { return this.#parent?.root ?? this; } get parent() { return this.#parent; } get path() { return this.#path.value; } get query() { return this.#query.value; } push(path, query) { batch(() => { this.#path.value = normalize(path); this.#query.value = Query.from(query); }); } replace(path, query) { this.push(path, query); } } const ROUTER = new Context(); function matchRoute(path, route) { if (typeof route.match === "string") { const test = route.match === "/" ? "" : route.match; if (test.endsWith("/")) { if (path.startsWith(test) || path === test.slice(0, -1)) { return { route, path: normalize(path.slice(0, test.length - 1)), params: undefined, rest: normalize(path.slice(test.length)), }; } } else if (test === path) { return { route, path, rest: "" }; } } else if (typeof route.match === "function") { const match = route.match(path); if (match !== undefined) { let rest = path; if (path.startsWith(match.path) && (path.length === match.path.length || path[match.path.length] === "/")) { rest = normalize(path.slice(match.path.length)); } return { ...match, route, rest }; } } else if (route.match instanceof RegExp) { const match = route.match.exec(path); if (match !== null) { const matched = normalize(match[0], false); let rest = path; if (path.startsWith(matched) && (path.length === matched.length || path[matched.length] === "/")) { rest = normalize(path.slice(matched.length)); } return { route, path: matched, params: match, rest }; } } else { return { route, path: "", rest: path }; } } function watchRoutes(path, routes) { const parent = $(undefined); const rest = $(undefined); watch(() => { const rest = get(path); for (const route of get(routes)) { const match = matchRoute(rest, route); if (match) { return match; } } }, match => { batch(() => { if (match) { if (!parent.value || parent.value.path !== match.path || parent.value.route !== match.route) { parent.value = match; } rest.value = match.rest; } else { parent.value = undefined; rest.value = ""; } }); }); return { match: () => parent.value, rest: () => rest.value, }; } function routes(routes) { const router = ROUTER.current; if (!router) { throw new Error("G3"); } const watched = watchRoutes(() => router.path, routes); return nest(watched.match, match => { if (match) { return ROUTER.provide(new ChildRouter(router, match.path, watched.rest), () => { return match.route.content({ params: match.params }); }); } }); } function Routes(props) { return routes(props.routes); } function isCurrent(match, router) { router ??= ROUTER.current; if (!router) { throw new Error("G3"); } return matchRoute(router.path, { match }) !== undefined; } export { ChildRouter, HashRouter, HistoryRouter, MemoryRouter, Query, ROUTER, Routes, formatQuery, isCurrent, join, matchRoute, normalize, relative, routes, watchRoutes };