vite-plugin-vanjs
Version:
An async first mini meta-framework for VanJS powered by Vite
264 lines (239 loc) • 7.83 kB
JavaScript
import van from "vanjs-core";
import isServer from "../setup/isServer.mjs";
import { routerState, setRouterState } from "./state.mjs";
import { matchRoute } from "./matchRoute.mjs";
import { unwrap } from "./unwrap.mjs";
import { hydrate } from "../client/index.mjs";
import { Head } from "../meta/index.mjs";
import * as dataCache from "./dataCache.mjs";
/** @typedef {typeof import("./types.d.ts").navigate} Navigate */
/** @typedef {import("./types.d.ts").SearchParamDef} SearchParamDef */
/** @typedef {import("./types.d.ts").Route} Route */
/** @typedef {import("./types.d.ts").VanNode} VanNode */
/** @typedef {import("./types.d.ts").ComponentModule} ComponentModule */
/** @typedef {import("./types.d.ts").ComponentFn} ComponentFn */
/** @typedef {import("./types.d.ts").LazyComponent} LazyComponent */
/**
* Update head tags
*/
const updateHead = () => {
// istanbul ignore else
if (document.head) {
hydrate(document.head, Head());
}
};
/**
* Resolve component children from a module
* @param {ComponentModule | Element | Element[] | any} module
* @returns {VanNode[]}
*/
export const resolveChildren = (module) => {
const isElement = typeof Element !== "undefined" && module instanceof Element;
const cp = (Array.isArray(module) || isElement)
? module
: typeof module.component === "function"
? module.component()
: module.component;
return cp ? Array.from(unwrap(cp).children) : /* istanbul ignore next */ [];
};
/**
* Returns the HREF string value
* @param {unknown} v
* @returns {string}
*/
export const getValue = (v) => {
return typeof v === "function" ? v() : v.rawVal ? v.val : v;
};
export const getCacheKey = () => {
const params = routerState.params;
const search = routerState.searchParams;
return Object.keys(params).length === 0
? search || ""
: JSON.stringify(params) + (search ? `&${search}` : "");
};
/**
* Check if selected page is the current page;
* @param {string} pageName
* @returns {boolean}
*/
export const isCurrentPage = (pageName) => {
const href = getValue(pageName);
const url = new URL(href, "http://localhost:5173");
// console.log({ href }, routerState.searchParams, url.searchParams.toString())
return routerState.pathname === url.pathname &&
routerState.searchParams === url.searchParams.toString();
};
/**
* Check if selected page is related to the current page;
* @param {string} pageName
* @returns {boolean}
*/
export const isCurrentLocation = (pageName) => {
const href = getValue(pageName);
const searchParams = new URLSearchParams(routerState.searchParams);
const pathname = routerState.pathname;
const url = new URL(href, "http://localhost:5173");
return url.pathname !== "/" && pathname.includes(url.pathname) ||
pathname === url.pathname && searchParams.size > 0;
};
/**
* Check if component is a lazy component
* @param {ComponentFn | (() => LazyComponent)} component
* @returns {component is (() => LazyComponent)}
*/
export const isLazyComponent = (component) => {
if (typeof component !== "function") return false;
// @ts-expect-error - this property is optional and on purpose
return component?.isLazy === true ||
component.constructor.name.includes("AsyncFunction");
};
/**
* Execute lifecycle methods preload and / or load
* @param {import("./types.d.ts").RouteEntry} route
* @returns {Promise<boolean>}
*/
export const executeLifecycle = async (route) => {
// istanbul ignore next
try {
if (!route) return true;
let data;
const preload = route.preload;
const load = route.load;
const pathname = routerState.pathname;
const cacheKey = getCacheKey();
if (preload) await preload(route.params);
if (!data && load && !dataCache.has(pathname, cacheKey)) {
data = await load(route.params);
} else if (load && dataCache.has(pathname, cacheKey)) {
dataCache.touch(pathname);
}
if (data) {
dataCache.set(pathname, cacheKey, {
data,
error: null,
status: "success",
timestamp: Date.now(),
});
if (!isServer) {
dataCache.touch(pathname);
}
}
return true;
} catch (error) {
// istanbul ignore next
console.error("Lifecycle execution error:", error);
// istanbul ignore next
return false;
}
};
/**
* @param {RouteEntry} route
* @param {HTMLElement} wrapper
* @param {boolean} ssr
* @returns
*/
export const executeModule = async (route, wrapper, ssr) => {
if (routerState.loading === true) return;
// 0. Set Loading State
routerState.loading = true;
// 1. Resolve the module first (to get route lifecycle hooks)
const module = await route.component();
// 2. Execute lifecycle (a complete route includes all props)
await executeLifecycle(Object.assign(route, module.route));
// 3. Resolve children
const children = resolveChildren(module);
// 4. Update <head> in the client
if (!isServer) updateHead();
// 5. Set Loading State
routerState.loading = false;
// 6. Update / replace children in wrapper
if (ssr) return van.add(wrapper, ...children);
else wrapper.replaceChildren(...children);
};
/**
* Convenience hook to get the current route's cached data.
* @returns {any | undefined}
*/
export const useRouteData = () => {
const key = getCacheKey();
return dataCache.get(routerState.pathname, key)?.data;
};
/**
* Client only navigation utility.
* @type {Navigate}
*/
export const navigate = (path, options = {}) => {
const { replace = false } = options;
// istanbul ignore else
if (!isServer) {
// Client-side navigation
const url = new URL(path, globalThis.location.origin);
const route = matchRoute(url.pathname);
// Update history
if (replace) {
globalThis.history.replaceState({}, "", path);
} else {
globalThis.history.pushState({}, "", path);
}
// Update router state
setRouterState(url.pathname, url.search, route?.params);
} else {
// Server-side navigation - throw error
console.error("Direct navigation is not supported on server");
}
};
/**
* Client only reload utility
* WORK IN PROGRESS
* @param {boolean} forceFetch - Force fetch from server
* @returns {void}
*/
// export const reload = (forceFetch = false) => {
// if (!isServer) {
// // Client-side reload
// if (forceFetch) {
// window.location.reload();
// } else {
// // Soft reload - just update router state
// const { pathname, search } = window.location;
// setRouterState(pathname, search);
// }
// } else {
// // Server-side reload - throw error
// console.error("Reload is not supported on server");
// }
// };
/**
* Isomorphic redirect utility
* WORK IN PROGRESS
* @param {string} path - The path to redirect to
* @param {object} options - Redirect options
* @param {number} options.status - HTTP status code (server-side only)
* @param {boolean} options.replace - Whether to replace current history entry (client-side only)
* @returns {void}
*/
// export const redirect = (path, options = {}) => {
// const { status = 302, replace = true } = options;
// if (!isServer) {
// // Client-side redirect
// navigate(path, { replace });
// } else {
// // Server-side redirect
// const error = new Error(`Redirect to ${path}`);
// error.status = status;
// error.location = path;
// throw error;
// }
// };
// Utility to handle server-side redirects in your server entry point
// export const handleServerRedirect = (error, res) => {
// if (error.location && error.status) {
// res.writeHead(error.status, {
// Location: error.location,
// "Content-Type": "text/plain",
// });
// res.end(`Redirecting to ${error.location}...`);
// return true;
// }
// return false;
// };