UNPKG

@underpostnet/underpost

Version:

Underpost Platform — end-to-end CI/CD and application-delivery toolchain CLI. Covers bare metal, Kubernetes, K3s, kubeadm, LXD, container/image orchestration, secrets, databases, cron jobs, monitoring, SSH, runners, PWA + Workbox delivery, and release orc

524 lines (471 loc) 20.3 kB
/** * Router module for handling routing in a PWA application. * @module src/client/components/core/Router.js * @namespace PwaRouter */ import { titleFormatted } from './CommonJs.js'; import { loggerFactory } from './Logger.js'; import { htmls, s } from './VanillaJs.js'; import { Modal, subMenuHandler } from './Modal.js'; import { Worker } from './Worker.js'; const logger = loggerFactory(import.meta, { trace: true }); /** * @type {function | null} * @description The active routes function registered by the current app router. * Replaces the former `window.Routes` global so `getProxyPath` can resolve * sub-directory proxy paths without polluting the global scope. * @memberof PwaRouter */ let _activeRoutes = null; /** * Registers the active routes function for the current app. * Called automatically by `LoadRouter`; should not be called manually. * @param {function} routesFn - A function returning the routes object. * @memberof PwaRouter */ const registerRoutes = (routesFn) => { _activeRoutes = routesFn; }; /** * @type {Object.<string, function>} * @description Holds event listeners for router changes. * @memberof PwaRouter */ const RouterEvents = {}; /** * @type {Object.<string, function>}\n * @description Holds event listeners for query parameter changes.\n * @memberof PwaRouter */ const queryParamsChangeListeners = {}; /** * @type {string[]} * @description Array of core UI component IDs that should not trigger modal close route changes. * @memberof PwaRouter */ const coreUI = ['modal-menu', 'main-body', 'main-body-top', 'bottom-bar', 'board-notification']; /** * @type {Object.<string, function>} * @description Holds event listeners for route changes that should close a modal. * @memberof PwaRouter */ const closeModalRouteChangeEvents = {}; /** * Deferred promise that resolves once the full UI (including deferred slide-menu DOM * setup in Modal) is ready. Any code that depends on the complete DOM — route handlers, * session callbacks, panel updates, etc. — can simply `await RouterReady` instead of * scattering individual null-checks across every microfrontend. * * Resolved by calling `setRouterReady()`, which should happen exactly once at the end * of Modal's deferred slide-menu `setTimeout` block. * @type {Promise<void>} * @memberof PwaRouter */ let _routerReadyResolve; const RouterReady = new Promise((resolve) => { _routerReadyResolve = resolve; }); /** * Signals that the deferred UI setup is complete and the router (and any other * awaiter of `RouterReady`) may safely access the full DOM. * This must be called exactly once – typically at the end of Modal's deferred * slide-menu `setTimeout` block. * @memberof PwaRouter */ const setRouterReady = () => { if (_routerReadyResolve) { _routerReadyResolve(); _routerReadyResolve = undefined; } }; /** * Determines the base path for the application, often used for routing within a sub-directory. * It checks the current URL's pathname and the active registered routes to return the appropriate proxy path. * * @returns {string} The calculated proxy path. Returns `/<first-segment>/` if a segment exists, * otherwise `/`. If the registered routes indicate the path is a root route, it returns `/`. * @memberof PwaRouter */ const getProxyPath = () => { let path = location.pathname.split('/')[1] ? `/${location.pathname.split('/')[1]}/` : '/'; if (_activeRoutes && path !== '/' && path.slice(0, -1) in _activeRoutes()) path = '/'; return path; }; /** * Sets the browser's path using the History API. It sanitizes the path, handles query strings and hashes, * and prevents pushing the same state twice unless forced or using replace mode. * @param {string} [path='/'] - The new path to set. Can include query strings and hashes. * @param {object} [options={ removeSearch: false, removeHash: false, replace: false, force: false }] - Options for path manipulation. * @param {boolean} [options.removeSearch=false] - If true, removes the search part of the URL. * @param {boolean} [options.removeHash=false] - If true, removes the hash part of the URL. Defaults to `false`. * @param {boolean} [options.replace=false] - If true, uses replaceState instead of pushState. * @param {boolean} [options.force=false] - If true, allows navigation to the same path (useful for query param changes). * @param {object} [stateStorage={}] - State object to associate with the history entry. * @param {string} [title=''] - The title for the new history entry. * @memberof PwaRouter * @returns {void | undefined} Returns `undefined` if the new path is the same as the current path and not forced, otherwise `void` (result of `history.pushState` or `history.replaceState`). */ const setPath = ( path = '/', options = { removeSearch: false, removeHash: false, replace: false, force: false }, stateStorage = {}, title = '', ) => { // logger.warn(`Set path input`, `${path}`); if (!path) path = '/'; let [inputPath, inputSearchHash] = `${path}`.split('?'); let [inputSearch, inputHash] = inputSearchHash ? inputSearchHash.split('#') : []; let sanitizedPath = (inputPath[0] !== '/' ? `/${inputPath}` : inputPath) .trim() .replaceAll('//', '/') .replaceAll(`\\`, '/'); if (sanitizedPath.length > 1 && sanitizedPath[sanitizedPath.length - 1] === '/') sanitizedPath = sanitizedPath.slice(0, -1); const newFullPath = `${sanitizedPath}${inputSearch && !options.removeSearch ? `?${inputSearch}` : ''}${ inputHash && !options.removeHash ? `#${inputHash}` : '' }`; const currentFullPath = `${window.location.pathname}${location.search}${location.hash}`; // logger.warn(`Set path output`, { // inputPath: inputPath, // inputSearch: inputSearch, // inputHash: inputHash, // sanitizedPath: sanitizedPath, // currentLocationSearch: location.search, // currentLocationHash: location.hash, // currentFullPath, // newFullPath, // }); if (currentFullPath === newFullPath && !options.force) { // logger.warn('Prevent overwriting same path', { currentFullPath, newFullPath }); return; } if (options.replace) { return history.replaceState.call(history, stateStorage, title, newFullPath); } else { return history.pushState.call(history, stateStorage, title, newFullPath); } }; /** * Extracts query parameters from the current URL's search string and returns them as an object. * @returns An object containing the query parameters from the current URL is being returned. * @memberof PwaRouter */ const getQueryParams = () => { const params = new URLSearchParams(window.location.search); let queries = {}; for (const param of params) { queries[param[0]] = param[1]; } return queries; }; /** * Sanitizes a route string for use in CSS classes or other identifiers. * Defaults to 'home' for empty, '/', or '\' routes. * @param {string} route - The route string to sanitize. * @returns {string} The sanitized route string. * @memberof PwaRouter */ const sanitizeRoute = (route) => !route || route === '/' || route === `\\` ? 'home' : route .toLowerCase() .replaceAll('/', '') .replaceAll(`\\`, '') .replaceAll(' ', '-') .replaceAll(getProxyPath().replaceAll('/', ''), ''); /** * Sets the document title and updates the active state of the main menu button corresponding to the route. * The title is formatted and appended with the main application title from `Worker.title` if it's not already present. * @param {string} route - The current route string. * @memberof PwaRouter */ const setDocTitle = (route) => { let _route = sanitizeRoute(route); // logger.warn('setDocTitle', _route); const title = titleFormatted(_route); htmls('title', html`${title}${title.match(Worker.title.toLowerCase()) ? '' : ` | ${Worker.title}`}`); const btnSelector = _route === 'u' ? 'public-profile' : _route; if (s(`.main-btn-${btnSelector}`)) { if (s(`.main-btn-menu-active`)) s(`.main-btn-menu-active`).classList.remove(`main-btn-menu-active`); if (s(`.main-btn-${btnSelector}`)) s(`.main-btn-${btnSelector}`).classList.add(`main-btn-menu-active`); } }; /** * Main router function. It matches the current URL path against the provided routes configuration * and renders the corresponding component. It also fires registered router events. * @param {object} [options={ Routes: () => {}, e: new PopStateEvent() }] - The router options. * @param {function} options.Routes - A function that returns the routes object. * @param {PopStateEvent} options.e - The popstate event object. * @memberof PwaRouter */ const Router = function (options = { Routes: () => {}, e: new PopStateEvent() }) { const { e, Routes } = options; const proxyPath = getProxyPath(); let path = window.location.pathname; // logger.info(options); for (let route of Object.keys(Routes())) { route = route.slice(1); let pushPath = `${proxyPath}${route}`; if (path[path.length - 1] !== '/') path = `${path}/`; // Handle clean profile URLs: match /u/username with /u route let matchPath = path; if (route === 'u' && path.startsWith(`${proxyPath}u/`) && path !== `${proxyPath}u/`) { handleCleanProfileUrl(path); matchPath = `${proxyPath}u/`; } if (matchPath[matchPath.length - 1] !== '/') matchPath = `${matchPath}/`; if (pushPath[pushPath.length - 1] !== '/') pushPath = `${pushPath}/`; const routerEvent = { path: matchPath, pushPath, route }; if (matchPath === pushPath) { for (const event of Object.keys(RouterEvents)) RouterEvents[event](routerEvent); subMenuHandler(Object.keys(Routes()), route); setDocTitle(route); return Routes()[`/${route}`].render(); } } }; /** * Initializes the router and sets up the `onpopstate` event listener to handle browser * back/forward navigation. * @param {object} RouterInstance - The router instance configuration, including the `Routes` function. * @memberof PwaRouter */ const LoadRouter = async function (RouterInstance) { await RouterReady; if (RouterInstance.Routes) registerRoutes(RouterInstance.Routes); Router(RouterInstance); window.onpopstate = (e) => { Router({ ...RouterInstance, e }); // Notify query params listeners on browser back/forward navigation const updatedParams = getQueryParams(); for (const listenerId in queryParamsChangeListeners) { if (Object.hasOwnProperty.call(queryParamsChangeListeners, listenerId)) { queryParamsChangeListeners[listenerId](updatedParams); } } }; }; /** * Sets the URL path with a specific query parameter, commonly used for content IDs. * This function constructs a new URI based on the proxy path, a given path, and an optional query parameter. * @param {object} [options={ path: '', queryPath: '' }] - The path options. * @param {string} [options.path=''] - The base path segment. * @param {string} [options.queryPath=''] - The query parameter value. * @param {string} [queryKey='cid'] - The query parameter key. * @memberof PwaRouter */ const setQueryPath = (options = { path: '', queryPath: '' }, queryKey = 'cid', navOptions = {}) => { const { queryPath, path } = options; const { replace = false } = navOptions; const newUri = `${getProxyPath()}${path === 'home' ? '' : `${path}`}${ typeof queryPath === 'string' && queryPath ? `?${queryKey}=${queryPath}` : '' }`; const currentUri = `${window.location.pathname}${location.search}`; // For query parameter changes on the same path, force the navigation to ensure proper history const isSamePath = window.location.pathname === new URL(newUri, window.location.origin).pathname; const isDifferentQuery = window.location.search !== new URL(newUri, window.location.origin).search; const shouldForce = isSamePath && isDifferentQuery; if (currentUri !== newUri && currentUri !== `${newUri}/`) { setPath(newUri, { force: shouldForce, replace }, ''); } }; /** * Extracts username from clean public profile URLs like /u/username. * @param {string} [pathname] - The pathname to extract from (defaults to current pathname). * @returns {string|null} The username if found, null otherwise. * @memberof PwaRouter */ const extractUsernameFromPath = (pathname = window.location.pathname) => { const proxyPath = getProxyPath(); const cleanPathPrefix = `${proxyPath}u/`.replace(/\/+/g, '/'); if (pathname.startsWith(cleanPathPrefix)) { const username = pathname.slice(cleanPathPrefix.length).split('/')[0]; return username || null; } return null; }; /** * Handles direct navigation to clean public profile URLs. * Converts clean URLs like /u/username to internal query format for SPA. * @param {string} [pathname] - The pathname to handle (defaults to current pathname). * @returns {boolean} True if this was a public profile URL that was handled. * @memberof PwaRouter */ const handleCleanProfileUrl = (pathname = window.location.pathname) => { const username = extractUsernameFromPath(pathname); if (username) { // Convert clean URL to internal query format for data fetching // Don't modify history - just return the username for the caller to use return username; } return null; }; /** * Navigates to a public profile URL without adding intermediate query URLs to history. * This ensures clean back/forward navigation between profiles. * @param {string} username - The username to navigate to. * @param {object} [options={}] - Navigation options. * @param {boolean} [options.replace=false] - If true, replaces current history entry instead of pushing. * @memberof PwaRouter */ const navigateToProfile = (username, options = {}) => { const { replace = false } = options; if (!username) return; const cleanPath = `${getProxyPath()}u/${username}`; const currentPath = window.location.pathname; // If we're already on this profile's clean URL, no navigation needed if (currentPath === cleanPath) return; // Navigate directly to clean URL, avoiding intermediate ?cid= URLs in history setPath(cleanPath, { replace }); }; /** * Registers a listener for route changes that specifically watches for a `queryKey` parameter * on a matching `routeId`. The provided event callback is triggered with the query parameter's value. * @param {object} options - The listener options. * @param {string} options.id - A unique ID for the listener. * @param {string} options.routeId - The route ID to listen for. * @param {function(string): void} options.event - The callback function to execute with the query path value (or an empty string if not found). * @param {string} [queryKey='cid'] - The query parameter key to look for. * @memberof PwaRouter */ const listenQueryPathInstance = ({ id, routeId, event }, queryKey = 'cid') => { RouterEvents[id] = ({ path, pushPath, proxyPath, route }) => { if ((route === '' && routeId === 'home') || (route && routeId && route === routeId)) { setTimeout(() => { const path = getQueryParams()[queryKey]; if (path) event(path); else event(''); }); } }; if (routeId && routeId !== 'home') setTimeout(() => { RouterEvents[id]({ route: routeId }); }); }; /** * Registers a listener for changes to query parameters.\n * The provided event callback is triggered with the current query parameters object.\n * @param {object} options - The listener options.\n * @param {string} options.id - A unique ID for the listener.\n * @param {function(Object.<string, string>): void} options.event - The callback function to execute with the new query parameters.\n * @memberof PwaRouter */ const listenQueryParamsChange = ({ id, event }) => { queryParamsChangeListeners[id] = event; // Immediately call with current query params for initial state setTimeout(() => { event(getQueryParams()); }); }; /** * Handles the logic for changing the route when a modal is closed. It determines the next URL * based on the remaining open modals or falls back to a home URL. * @param {object} [options={}] - Options for the modal close event. * @param {string} options.closedId - The ID of the modal that was just closed. * @memberof PwaRouter */ const closeModalRouteChangeEvent = (options = {}) => { // logger.warn('closeModalRouteChangeEvent', options); const { closedId } = options; if (!closedId) return; if (coreUI.find((id) => closedId.startsWith(id))) { // logger.warn('prevent core ui component close'); return; } const remainingModals = Object.keys(Modal.Data).filter( (id) => id !== closedId && (Modal.Data[id]?.options?.route || Modal.Data[id]?.options?.query), ); const topModalId = remainingModals.reverse().find((id) => Modal.Data[id]); for (const event of Object.keys(closeModalRouteChangeEvents)) closeModalRouteChangeEvents[event](); if (topModalId) Modal.setTopModalCallback(topModalId); setPath(`${getProxyPath()}${Modal.Data[topModalId]?.options?.route ?? ''}`); setDocTitle(Modal.Data[topModalId]?.options?.route ?? ''); }; /** * Handles routing for modals that are meant to be displayed as a "view" (e.g., a full-page modal). * It updates the URL to reflect the modal's route. * @param {object} [options={ route: 'home' }] - The options for handling the modal view route. * @param {string} options.route - The route associated with the modal view. * @memberof PwaRouter */ const handleModalViewRoute = (options = { RouterInstance: { Routes: () => {} }, route: '' }) => { const { route, RouterInstance } = options; if (!route) return; let path = window.location.pathname; if (path !== '/' && path[path.length - 1] === '/') path = path.slice(0, -1); const proxyPath = getProxyPath(); const newPath = `${proxyPath}${route}`; if (RouterInstance && RouterInstance.Routes) subMenuHandler(Object.keys(RouterInstance.Routes()), route); // Check if we're already on this route or a sub-path of it (e.g., /u/username for route 'u') // Don't push to history if already on the route or its sub-path const routeBasePath = `${proxyPath}${route}`; const isOnRouteOrSubPath = path === newPath || path.startsWith(`${routeBasePath}/`); if (!isOnRouteOrSubPath) { setPath(newPath); setDocTitle(newPath); } }; /** * Sets or updates query parameters in the URL. * It preserves the existing path, hash, and other query parameters. * * @param {Object.<string, string|number>} newParams - An object of query parameters to set or update. * If a value is `null` or `undefined`, the parameter will be removed. * @param {object} [options={ replace: true }] - Options for history manipulation. * @param {boolean} [options.replace=true] - If true, uses `history.replaceState` instead of `history.pushState`. * @memberof PwaRouter */ const setQueryParams = (newParams, options = { replace: true }) => { const url = new URL(window.location.href); Object.entries(newParams).forEach(([key, value]) => { if (value === null || value === undefined) { url.searchParams.delete(key); } else { url.searchParams.set(key, value); } }); const newPath = url.pathname + url.search + url.hash; const currentPath = window.location.pathname + window.location.search + window.location.hash; // Only update history and trigger listeners if the URL actually changed if (newPath === currentPath) { return; } if (options.replace) { history.replaceState(history.state, '', newPath); } else { history.pushState(history.state, '', newPath); } const updatedParams = getQueryParams(); for (const listenerId in queryParamsChangeListeners) { if (Object.hasOwnProperty.call(queryParamsChangeListeners, listenerId)) { queryParamsChangeListeners[listenerId](updatedParams); } } }; export { RouterEvents, registerRoutes, navigateToProfile, closeModalRouteChangeEvents, coreUI, Router, setDocTitle, LoadRouter, setQueryPath, extractUsernameFromPath, handleCleanProfileUrl, listenQueryPathInstance, closeModalRouteChangeEvent, handleModalViewRoute, getQueryParams, getProxyPath, setPath, setQueryParams, sanitizeRoute, queryParamsChangeListeners, listenQueryParamsChange, setRouterReady, RouterReady, };