rvx
Version:
A signal based rendering library
364 lines (352 loc) • 8.93 kB
JavaScript
/*!
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 };