hybrids
Version:
A JavaScript framework for creating fully-featured web applications, components libraries, and single web components with unique declarative and functional architecture
1,215 lines (992 loc) • 29.7 kB
JavaScript
import * as cache from "./cache.js";
import { constructors } from "./define.js";
import transition from "./template/helpers/transition.js";
import {
deferred,
dispatch,
walkInShadow,
debug,
isDebugMode,
} from "./utils.js";
const connect = Symbol("router.connect");
const configs = new WeakMap();
const flushes = new WeakMap();
const stacks = new WeakMap();
const routers = new WeakMap();
let rootRouter = null;
const entryPoints = new Set();
const scrollMap = new WeakMap();
const focusMap = new WeakMap();
function saveLayout() {
const target = stacks.get(rootRouter)[0];
if (!target) return;
const focusEl = globalThis.document.activeElement;
focusMap.set(target, rootRouter.contains(focusEl) && focusEl);
const map = new Map();
for (const el of [
globalThis.document.documentElement,
globalThis.document.body,
]) {
map.set(el, { left: el.scrollLeft, top: el.scrollTop });
}
walkInShadow(target, (el) => {
if (el.scrollLeft || el.scrollTop) {
map.set(el, { left: el.scrollLeft, top: el.scrollTop });
}
});
scrollMap.set(target, map);
}
function focusElement(target) {
if (target.tabIndex === -1) {
const outline = target.style.outline;
target.tabIndex = 0;
target.style.outline = "none";
target.addEventListener(
"blur",
() => {
target.removeAttribute("tabindex");
target.style.outline = outline;
},
{ once: true },
);
}
target.focus({ preventScroll: true });
}
async function restoreLayout(target) {
const activeEl = globalThis.document.activeElement;
await deferred;
// Wait until transition has started (by document.startViewTransition call)
transition.instance && (await transition.instance.ready);
focusElement(
focusMap.get(target) ||
(rootRouter.contains(activeEl) ? activeEl : rootRouter),
);
const map = scrollMap.get(target);
if (map) {
const config = configs.get(target);
const state = globalThis.history.state;
const entry = state.find((e) => e.id === config.id);
const clear = entry && entry.params.scrollToTop;
for (const [el, { left, top }] of map) {
el.scrollLeft = clear ? 0 : left;
el.scrollTop = clear ? 0 : top;
}
scrollMap.delete(target);
} else {
for (const el of [
globalThis.document.documentElement,
globalThis.document.body,
]) {
el.scrollLeft = 0;
el.scrollTop = 0;
}
}
setTimeout(() => {
globalThis.history.scrollRestoration = "auto";
}, 0);
}
function mapUrlParam(value) {
return value === true ? 1 : value || "";
}
const metaParams = ["scrollToTop"];
function setupBrowserUrl(browserUrl, id) {
const [pathname, search = ""] = browserUrl.split("?");
const searchParams = search ? search.split(",") : [];
const normalizedPathname = pathname.replace(/^\//, "").split("/");
const pathnameParams = normalizedPathname.reduce((params, name) => {
if (name.startsWith(":")) {
const key = name.slice(1);
if (searchParams.includes(key)) {
throw Error(`The '${key}' already used in search params`);
}
if (params.includes(key)) {
throw Error(`The '${key}' already used in pathname`);
}
params.push(key);
}
return params;
}, []);
return {
browserUrl,
pathnameParams,
paramsKeys: [...searchParams, ...pathnameParams],
url(params, strict = false) {
let temp = "";
for (let part of normalizedPathname) {
if (part.startsWith(":")) {
const key = part.slice(1);
if (!hasOwnProperty.call(params, key)) {
throw Error(`The '${key}' parameter must be defined for <${id}>`);
}
part = mapUrlParam(params[key]);
}
temp += `/${part}`;
}
const url = new URL(temp, globalThis.location.origin);
for (const key of Object.keys(params)) {
if (
pathnameParams.includes(key) ||
(strict && (metaParams.includes(key) || !searchParams.includes(key)))
) {
continue;
}
url.searchParams.append(key, mapUrlParam(params[key]));
}
return url;
},
match(url) {
const params = {};
const temp = url.pathname.replace(/^\//, "").split("/");
if (temp.length !== normalizedPathname.length) return null;
for (let i = 0; i < temp.length; i += 1) {
const part = temp[i];
const normalizedPart = normalizedPathname[i];
if (normalizedPart.startsWith(":")) {
const key = normalizedPart.slice(1);
params[key] = part;
} else if (part !== normalizedPart) {
return null;
}
}
for (const [key, value] of url.searchParams) {
params[key] = value;
}
return params;
},
};
}
function hasInStack(config, target) {
return config.stack.some((temp) => {
if (temp === target) return true;
return hasInStack(temp, target);
});
}
function addEntryPoint(config) {
if (config.browserUrl) {
entryPoints.add(config);
}
for (const child of config.stack) {
addEntryPoint(child);
}
}
function setupViews(views, options, parent = null, nestedParent = null) {
if (typeof views === "function") views = views();
views = [].concat(views);
return views.map((hybrids) => {
const config = configs.get(hybrids);
if (config && hasInStack(config, parent)) {
throw Error(
`<${config.id}> cannot be in the stack of <${parent.id}>, as it is an ancestor in the stack tree`,
);
}
return setupView(hybrids, options, parent, nestedParent);
});
}
function getNestedRouterOptions(hybrids, config) {
const nestedRouters = Object.values(hybrids)
.map((desc) => routers.get(desc))
.filter((d) => d);
if (nestedRouters.length) {
if (nestedRouters.length > 1) {
throw TypeError(
`<${config.id}> must contain at most one nested router, found: ${nestedRouters.length}`,
);
}
if (config.dialog) {
throw TypeError(
`Nested routers are not supported in dialogs. Remove the router property definition from <${config.id}>`,
);
}
if (config.browserUrl) {
throw TypeError(
`A view with nested router must not have the url option. Remove the url option from <${config.id}>`,
);
}
}
return nestedRouters[0];
}
function getConfigById(id) {
const Constructor = globalThis.customElements.get(id);
return configs.get(Constructor);
}
function setupView(hybrids, routerOptions, parent, nestedParent) {
const id = hybrids.tag;
let config = getConfigById(id);
if (config && config.hybrids !== hybrids) {
config = null;
}
if (!config) {
const Constructor = globalThis.customElements.get(id);
if (!Constructor || constructors.get(Constructor) !== hybrids) {
throw Error(
`<${id}> view must be defined by 'define()' function before it can be used in router factory`,
);
}
let browserUrl = null;
const options = {
dialog: false,
guard: false,
multiple: false,
replace: false,
...hybrids[connect],
};
const { connects } = Constructor;
if (options.dialog) {
connects.add((host) => {
const root = rootRouter;
const goBackOnEscKey = (event) => {
const stack = stacks.get(root);
if (stack[0] === host && event.key === "Escape") {
event.stopPropagation();
globalThis.history.go(-1);
}
};
const focusDialog = (event) => {
const stack = stacks.get(root);
if (
stack[0] === host &&
!host.contains(event.target) &&
event.target !== host
) {
focusElement(host);
}
};
root.addEventListener("focusin", focusDialog);
root.addEventListener("focusout", focusDialog);
host.addEventListener("keydown", goBackOnEscKey);
focusElement(host);
return () => {
root.removeEventListener("focusin", focusDialog);
root.removeEventListener("focusout", focusDialog);
host.removeEventListener("keydown", goBackOnEscKey);
};
});
}
if (options.url) {
if (options.dialog) {
throw Error(
`The 'url' option is not supported for dialogs - remove it from <${id}>`,
);
}
if (typeof options.url !== "string") {
throw TypeError(
`The 'url' option in <${id}> must be a string: ${typeof options.url}`,
);
}
browserUrl = setupBrowserUrl(options.url, id);
for (const key of browserUrl.paramsKeys) {
const desc = Object.getOwnPropertyDescriptor(
Constructor.prototype,
key,
);
if (!desc || !desc.set) {
throw Error(
`'${key}' parameter from the url is not ${
desc ? "writable" : "defined"
} in <${id}>`,
);
}
}
}
const writableParams = [...Constructor.writable];
const stateParams = writableParams.filter(
(k) => !routerOptions.params.includes(k) && !metaParams.includes(k),
);
const clearParams = browserUrl
? stateParams.filter((k) => !browserUrl.pathnameParams.includes(k))
: stateParams;
connects.add((_) =>
cache.observe(
_,
connect,
(host) => {
const params = {};
for (const key of stateParams) {
let value = host[key];
if (
value === undefined ||
value === hybrids[key] ||
(typeof value === "object" &&
value.toString === Object.prototype.toString)
) {
params[key] = undefined;
} else {
params[key] = mapUrlParam(value).toString();
}
}
return params;
},
(host, params, lastParams) => {
if (!lastParams || !globalThis.history.state) return;
const state = globalThis.history.state;
const index = state.findIndex((entry) => {
if (entry.id === id) return true;
if (entry.nested) {
let nested = entry.nested;
while (nested) {
if (nested.id === id) return true;
nested = nested.nested;
}
}
});
let entry = state[index];
while (entry.id !== id && entry.nested) entry = entry.nested;
params = { ...entry.params, ...params };
for (const key of clearParams) {
if (params[key] === undefined) delete params[key];
}
globalThis.history.replaceState(
state.map((entry, i) =>
i === index ? config.getEntry(params) : entry,
),
"",
browserUrl ? config.url(params, true) : "",
);
},
),
);
let guard;
if (options.guard) {
guard = () => {
try {
return options.guard();
} catch (e) {
console.error(e);
return false;
}
};
}
config = {
id,
hybrids,
dialog: options.dialog,
multiple: options.multiple,
replace: options.replace,
guard,
parent,
nestedParent,
nestedRoots: undefined,
parentsWithGuards: undefined,
stack: [],
...(browserUrl || {
url(params) {
const url = new URL("", globalThis.location.origin);
for (const key of Object.keys(params)) {
url.searchParams.append(key, mapUrlParam(params[key]));
}
return new URL(
`${routerOptions.url}#@${id}${url.search}`,
globalThis.location.origin,
);
},
match(url) {
const params = {};
for (const [key, value] of url.searchParams) {
if (writableParams.includes(key) || metaParams.includes(key))
params[key] = value;
}
return params;
},
}),
create() {
const el = new Constructor();
configs.set(el, config);
return el;
},
getEntry(params = {}, other) {
let entryParams = {};
for (const key of Object.keys(params)) {
if (writableParams.includes(key)) {
entryParams[key] = params[key];
}
}
const entry = { id, params: entryParams, ...other };
const guardConfig = config.parentsWithGuards.find((c) => !c.guard());
if (guardConfig) {
return guardConfig.getEntry(params, { from: entry });
}
if (config.guard && config.guard()) {
return { ...config.stack[0].getEntry(params) };
}
if (config.nestedParent) {
return config.nestedParent.getEntry(params, { nested: entry });
}
for (const key of metaParams) {
if (hasOwnProperty.call(params, key)) {
entry.params[key] = params[key];
}
}
return entry;
},
};
configs.set(hybrids, config);
configs.set(Constructor, config);
if (parent && !parent.stack.includes(config)) {
parent.stack.push(config);
}
if (options.stack) {
if (options.dialog) {
throw Error(
`The 'stack' option is not supported for dialogs - remove it from <${id}>`,
);
}
setupViews(options.stack, routerOptions, config, nestedParent);
}
} else {
config.parent = parent;
config.nestedParent = nestedParent;
if (parent && !parent.stack.includes(config)) {
parent.stack.push(config);
}
}
if (!parent) {
addEntryPoint(config);
}
config.parentsWithGuards = [];
while (parent) {
if (parent.guard) config.parentsWithGuards.unshift(parent);
parent = parent.parent;
}
const nestedRouterOptions = getNestedRouterOptions(hybrids, config);
if (nestedRouterOptions) {
config.nestedRoots = setupViews(
nestedRouterOptions.views,
{ ...routerOptions, ...nestedRouterOptions },
config,
config,
);
config.stack = config.stack.concat(config.nestedRoots);
}
return config;
}
function getUrl(view, params = {}) {
const config = configs.get(view);
return config ? config.url(params) : "";
}
function getAllEntryParams(entry) {
const params = {};
while (entry) {
Object.assign(params, entry.params);
entry = entry.nested;
}
return params;
}
function getBackUrl({ nested = false, scrollToTop = false } = {}) {
const state = globalThis.history.state;
if (!state) return "";
if (state.length > 1) {
const entry = state[0];
let i = 1;
let prevEntry = state[i];
if (nested) {
while (prevEntry.nested) {
prevEntry = prevEntry.nested;
}
} else {
while (entry.id === prevEntry.id && i < state.length - 1) {
i += 1;
prevEntry = state[i];
}
}
const params = getAllEntryParams(state[i]);
if (scrollToTop) {
params.scrollToTop = true;
} else {
delete params.scrollToTop;
}
return getConfigById(prevEntry.id).url(params);
}
let entry = state[0];
if (nested) {
while (entry.nested) {
entry = entry.nested;
}
}
let config = getConfigById(entry.id).parent;
if (config) {
while (config && config.guard) {
config = config.parent;
}
if (config) {
return config.url(getAllEntryParams(state[0]));
}
}
return "";
}
function getGuardUrl(params = {}) {
const state = globalThis.history.state;
if (!state) return "";
const entry = state[0];
if (entry.from) {
const config = getConfigById(entry.from.id);
return config.url({ ...entry.from.params, ...params });
}
const config = getConfigById(entry.id);
return config.stack[0] ? config.stack[0].url(params) : "";
}
function getCurrentUrl(params) {
const state = globalThis.history.state;
if (!state) return "";
let entry = state[0];
while (entry.nested) entry = entry.nested;
const config = getConfigById(entry.id);
return config.url({ ...entry.params, ...params });
}
function active(views, { stack = false } = {}) {
const state = globalThis.history.state;
if (!state) return false;
views = [].concat(views);
return views.some((view) => {
const config = configs.get(view);
if (!config) {
throw TypeError(`Provided view is not connected to the router: ${view}`);
}
let entry = state[0];
while (entry) {
const target = getConfigById(entry.id);
if (target === config || (stack && hasInStack(config, target))) {
return true;
}
entry = entry.nested;
}
return false;
});
}
function getEntryFromURL(url) {
let config;
const [pathname, search] = url.hash.split("?");
if (pathname && pathname.match(/^#@.+-.+/)) {
config = getConfigById(pathname.split("@")[1]);
url = new URL(`?${search}`, globalThis.location.origin);
}
if (!config) {
for (const entryPoint of entryPoints) {
const params = entryPoint.match(url);
if (params) return entryPoint.getEntry(params);
}
return null;
}
return config.getEntry(config.match(url));
}
function handleNavigate(event) {
if (event.defaultPrevented) return;
let url;
if (event.type === "click") {
if (event.ctrlKey || event.metaKey) return;
const anchorEl = event
.composedPath()
.find((el) => el instanceof globalThis.HTMLAnchorElement);
if (anchorEl) {
url = new URL(anchorEl.href, globalThis.location.origin);
}
} else {
url = new URL(event.target.action, globalThis.location.origin);
}
if (url && url.origin === globalThis.location.origin) {
const entry = getEntryFromURL(url);
if (entry) {
event.preventDefault();
dispatch(rootRouter, "navigate", {
bubbles: true,
detail: { entry, url },
});
}
}
}
let activePromise;
function resolveEvent(event, promise) {
event.preventDefault();
activePromise = promise;
const path = event.composedPath();
const pseudoEvent = {
type: event.type,
ctrlKey: event.ctrlKey,
metaKey: event.metaKey,
target: event.target,
defaultPrevented: false,
preventDefault: () => {},
composedPath: () => path,
};
return promise.then(() => {
if (promise === activePromise) {
handleNavigate(pseudoEvent);
activePromise = null;
}
});
}
function resolveStack(host, state, options) {
let stack = stacks.get(host);
const reducedState = [];
for (const [index, entry] of state.entries()) {
if (
index === 0 ||
state[index - 1].id !== entry.id ||
getConfigById(entry.id).multiple
) {
reducedState.push(entry);
}
}
const offset = stack.length - reducedState.length;
stack = reducedState.map((entry, index) => {
const prevView = stack[index + offset];
const config = getConfigById(entry.id);
let nextView;
if (prevView) {
const prevConfig = configs.get(prevView);
if (config.id !== prevConfig.id || (index === 0 && config.replace)) {
return config.create();
}
nextView = prevView;
} else {
nextView = config.create();
}
return nextView;
});
stacks.set(host, stack);
const view = stack[0];
const flush = flushes.get(view);
for (const [key, value] of Object.entries(state[0].params)) {
if (key in view) view[key] = value;
}
for (const key of options.params) {
if (key in view) view[key] = host[key];
}
if (flush) flush();
return stack;
}
function getEntryOffset(entry) {
const state = [];
for (let [index, e] of globalThis.history.state.entries()) {
let i = 0;
while (e) {
state[i] = state[i] || [];
state[i][index] = e;
e = e.nested;
i += 1;
}
}
let offset = 0;
let i = 0;
while (entry) {
const config = getConfigById(entry.id);
let j = offset;
for (; j < state[i].length; j += 1) {
const e = state[i][j];
if (config.dialog) {
if (e.id === entry.id) return j;
continue;
}
if (e.id === entry.id) {
if (config.multiple) {
if (
(config.pathnameParams &&
config.pathnameParams.every(
(key) => entry.params[key] === e.params[key],
)) ||
Object.entries(entry.params).every(
([key, value]) => e.params[key] === value,
)
) {
offset = j;
break;
}
} else {
offset = j;
break;
}
}
const c = getConfigById(e.id);
if (hasInStack(c, config)) {
if (config.multiple && state[i][0].id === entry.id) {
offset -= 1;
break;
}
if (j > 0) {
offset = j - 1;
break;
} else {
return c.guard ? 0 : -1;
}
}
}
if (config.dialog) return -1;
if (j === state[i].length) {
offset = state[i].length - 1;
}
entry = entry.nested;
i += 1;
}
return offset;
}
function setTransitionAttr(stack, prevStack) {
const el = globalThis.document.documentElement;
if (stack && prevStack.length > 0) {
let value = "";
if (stack.length > prevStack.length) {
value = "forward";
if (configs.get(stack[0].constructor).dialog) {
value += " dialog";
}
} else if (stack.length < prevStack.length) {
value = "backward";
if (configs.get(prevStack[0].constructor).dialog) {
value += " dialog";
}
} else if (stack[0] !== prevStack[0]) {
value = "replace";
}
el.setAttribute("router-transition", value);
} else {
el.removeAttribute("router-transition");
}
}
function connectRootRouter(host, invalidate, options) {
function flush() {
const prevStack = stacks.get(host);
const stack = resolveStack(host, globalThis.history.state, options);
if (options.transition) setTransitionAttr(stack, prevStack);
invalidate();
const el = stack[0];
if (!configs.get(el).dialog) {
restoreLayout(el);
}
}
function handlePopstate() {
// URL have changed externally, eg. chrome.tabs.update API
if (!globalThis.history.state) {
const url = new URL(globalThis.location.href);
const entry = getEntryFromURL(url);
if (entry) {
globalThis.removeEventListener("popstate", handlePopstate);
globalThis.addEventListener(
"popstate",
() => {
globalThis.addEventListener("popstate", handlePopstate);
navigate(entry);
},
{ once: true },
);
globalThis.history.back();
}
} else {
flush();
}
}
function navigateBack(offset, entry, nextUrl) {
const state = globalThis.history.state;
const targetEntry = globalThis.history.state[offset];
const pushOffset = offset < state.length - 1 && state.length > 2 ? 1 : 0;
offset += pushOffset;
if (targetEntry && entry.id === targetEntry.id) {
entry = { ...targetEntry, ...entry };
}
const replace = (popStateEvent) => {
if (popStateEvent) {
globalThis.removeEventListener("popstate", replace);
globalThis.addEventListener("popstate", handlePopstate);
}
const method = pushOffset ? "pushState" : "replaceState";
const nextState = [entry, ...state.slice(offset + (pushOffset ? 0 : 1))];
globalThis.history[method](nextState, "", nextUrl);
flush();
};
if (offset) {
globalThis.removeEventListener("popstate", handlePopstate);
globalThis.addEventListener("popstate", replace);
globalThis.history.go(-offset);
} else {
saveLayout();
replace();
}
}
function navigate(entry) {
const state = globalThis.history.state;
let nestedEntry = entry;
while (nestedEntry.nested) nestedEntry = nestedEntry.nested;
const nestedConfig = getConfigById(nestedEntry.id);
const url = nestedConfig.browserUrl
? nestedConfig.url(nestedEntry.params, true)
: options.url;
const offset = getEntryOffset(entry);
if (offset > -1) {
navigateBack(offset, entry, url);
} else {
saveLayout();
globalThis.history.scrollRestoration = "manual";
globalThis.history.pushState([entry, ...state], "", url);
flush();
}
}
function executeNavigate(event) {
navigate(event.detail.entry);
}
if (rootRouter) {
throw Error(
`An element with root router already connected to the document: <${rootRouter.tagName.toLowerCase()}>`,
);
}
let roots;
try {
roots = setupViews(options.views, options);
rootRouter = host;
flushes.set(host, flush);
} catch (e) {
console.error(
`Error while connecting router in <${host.tagName.toLowerCase()}>:`,
);
throw e;
}
const state = globalThis.history.state;
const bootstrapURL = new URL(globalThis.location.href);
if (!state) {
const entry = getEntryFromURL(bootstrapURL) || roots[0].getEntry();
globalThis.history.replaceState([entry], "", options.url);
flush();
} else {
const stack = stacks.get(host);
let i;
for (i = state.length - 1; i >= 0; i -= 1) {
let entry = state[i];
while (entry) {
const config = getConfigById(entry.id);
if (
!config ||
(config.dialog && stack.length === 0) ||
(!roots.includes(config) && !roots.some((c) => hasInStack(c, config)))
) {
break;
}
entry = entry.nested;
}
if (entry) break;
}
if (i > -1) {
const lastValidEntry = state[i + 1];
navigateBack(
state.length - i - 1,
lastValidEntry || roots[0].getEntry(state[0].params),
options.url,
);
} else {
let entry = state[0];
while (entry.nested) entry = entry.nested;
const nestedConfig = getConfigById(entry.id);
const resultEntry = nestedConfig.getEntry(entry.params);
navigate(resultEntry);
}
}
globalThis.addEventListener("popstate", handlePopstate);
host.addEventListener("click", handleNavigate);
host.addEventListener("submit", handleNavigate);
host.addEventListener("navigate", executeNavigate);
return () => {
globalThis.removeEventListener("popstate", handlePopstate);
host.removeEventListener("click", handleNavigate);
host.removeEventListener("submit", handleNavigate);
host.removeEventListener("navigate", executeNavigate);
setTransitionAttr(null);
entryPoints.clear();
rootRouter = null;
const length = globalThis.history.state && globalThis.history.state.length;
if (length > 1) {
globalThis.history.go(1 - length);
globalThis.history.replaceState(state, "", bootstrapURL);
}
};
}
function connectNestedRouter(host, invalidate, options) {
const config = configs.get(host);
function getNestedState() {
return globalThis.history.state
.map((entry) => {
while (entry) {
if (entry.id === config.id) return entry.nested;
entry = entry.nested;
}
return entry;
})
.filter((e) => e);
}
function flush() {
resolveStack(host, getNestedState(), options);
invalidate();
}
if (!getNestedState()[0]) {
const state = globalThis.history.state;
globalThis.history.replaceState(
[config.nestedRoots[0].getEntry(state[0].params), ...state.slice(1)],
"",
);
}
flush();
flushes.set(host, flush);
}
function router(views, options) {
options = {
url: globalThis.location.href.replace(/#.*$/, ""),
params: [],
...options,
views,
};
const desc = {
value: (host) => {
const stack = stacks.get(host) || [];
return stack
.slice(0, stack.findIndex((el) => !configs.get(el).dialog) + 1)
.reverse();
},
connect: (host, _, invalidate) => {
for (const param of options.params) {
if (!(param in host)) {
throw Error(
`Property '${param}' for global parameters is not defined in <${host.tagName.toLowerCase()}>`,
);
}
}
if (!stacks.has(host)) stacks.set(host, []);
if (configs.has(host)) {
return connectNestedRouter(host, invalidate, options);
}
return connectRootRouter(host, invalidate, options);
},
observe:
isDebugMode() &&
((host, value, lastValue) => {
const index = value.length - 1;
const view = value[index];
if (lastValue && view === lastValue[index]) return;
let config = configs.get(host);
let entry = globalThis.history.state[0];
let key = 0;
while (config) {
key += 1;
entry = entry.nested;
config = config.nestedParent;
}
console.groupCollapsed(
`[${host.tagName.toLowerCase()}]: navigated to <${
entry.id
}> ($$${key})`,
);
for (const [k, v] of Object.entries(entry.params)) {
console.log(`%c${k}:`, "font-weight: bold", v);
}
console.groupEnd();
globalThis[`$$${key}`] = view;
}),
};
routers.set(desc, options);
return desc;
}
export default Object.freeze(
Object.assign(router, {
connect,
debug,
url: getUrl,
backUrl: getBackUrl,
guardUrl: getGuardUrl,
currentUrl: getCurrentUrl,
resolve: resolveEvent,
active,
}),
);