rvx
Version:
A signal based rendering library
357 lines (342 loc) • 9.04 kB
JavaScript
/*!
MIT License
Copyright (c) 2025 Max J. Polster
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
*/
import { get, ENV, $, teardown, batch, Context, watch, nest } 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 {
#path = $(undefined);
#query = $(undefined);
constructor(options) {
this.#path.value = normalize(options?.path ?? "");
this.#query.value = Query.from(options?.query);
}
get root() {
return this;
}
get parent() {
return undefined;
}
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, routes) {
for (const route of routes) {
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(() => matchRoute(get(path), get(routes)), match => {
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.inject(new ChildRouter(router, match.path, watched.rest), () => {
return match.route.content({ params: match.params });
});
}
});
}
function Routes(props) {
return routes(props.routes);
}
export { ChildRouter, HashRouter, HistoryRouter, MemoryRouter, Query, ROUTER, Routes, formatQuery, join, matchRoute, normalize, relative, routes, watchRoutes };