UNPKG

@nent/core

Version:

Functional elements to add routing, data-binding, dynamic HTML, declarative actions, audio, video, and so much more. Supercharge static HTML files into web apps without script or builds.

467 lines (466 loc) 17.9 kB
/*! * NENT 2022 */ import { warn } from '../../../services/common/logging'; import { commonState, onCommonStateChange, } from '../../../services/common/state'; import { addDataProvider } from '../../../services/data/factory'; import { DATA_EVENTS } from '../../../services/data/interfaces'; import { Route } from '../../n-view/services/route'; import { getSessionVisits, getStoredVisits, getVisits, } from '../../n-view/services/visits'; import { NavigationActionListener } from './actions'; import { captureElementsEventOnce } from './elements'; import { HistoryService } from './history'; import { RoutingDataProvider } from './provider'; import { isAbsolute, resolvePathname } from './utils/location'; import { addLeadingSlash, ensureBasename, hasBasename, isFilename, stripBasename, } from './utils/path'; import { matchPath } from './utils/path-match'; /* The RouterService is responsible for managing the browser history and the routes that are registered with the router */ export class RouterService { /** * It creates a new instance of the NavigationService class * @param {Window} win - Window - the window object * @param writeTask - (t: RafCallback) => void * @param {IEventEmitter} eventBus - IEventEmitter - this is the event bus that the router uses to * communicate with the rest of the application. * @param {IEventEmitter} actions - IEventEmitter - this is the actions object that is passed to the * app. * @param {string} [root] - The root of the application. * @param {string} [appTitle] - The title of the app. * @param {string} [appDescription] - string = '', * @param {string} [appKeywords] - string = '', * @param {string} [transition] - string = '', * @param [scrollTopOffset=0] - This is the number of pixels from the top of the page that the * browser should scroll to when a new page is loaded. */ constructor(win, writeTask, eventBus, actions, root = '', appTitle = '', appDescription = '', appKeywords = '', transition = '', scrollTopOffset = 0) { this.win = win; this.writeTask = writeTask; this.eventBus = eventBus; this.actions = actions; this.root = root; this.appTitle = appTitle; this.appDescription = appDescription; this.appKeywords = appKeywords; this.transition = transition; this.scrollTopOffset = scrollTopOffset; this.routes = []; this.history = new HistoryService(win, root); this.listener = new NavigationActionListener(this, this.eventBus, this.actions); if (commonState.dataEnabled) this.enableDataProviders(); else { const dataSubscription = onCommonStateChange('dataEnabled', enabled => { if (enabled) { this.enableDataProviders(); } dataSubscription(); }); } this.removeHandler = this.history.listen((location) => { var _a; this.location = location; this.listener.notifyRouteChanged(location); (_a = this.routeData) === null || _a === void 0 ? void 0 : _a.changed.emit(DATA_EVENTS.DataChanged, { changed: ['route'], }); }); this.listener.notifyRouteChanged(this.history.location); } /** * It adds three data providers to the data provider registry */ async enableDataProviders() { this.routeData = new RoutingDataProvider((key) => { let route = { data: this.location.params }; if (this.hasExactRoute()) route = Object.assign(route, this.exactRoute); return route.data[key] || route[key]; }); addDataProvider('route', this.routeData); this.queryData = new RoutingDataProvider((key) => this.location.query[key]); addDataProvider('query', this.queryData); this.queryData = new RoutingDataProvider((key) => this.location.query[key]); this.visitData = new RoutingDataProvider(async (key) => { switch (key) { case 'all': const all = await getVisits(); return JSON.stringify(all).split(`"`).join(`'`); case 'stored': const stored = await getStoredVisits(); return JSON.stringify(stored).split(`"`).join(`'`); case 'session': const session = await getSessionVisits(); return JSON.stringify(session).split(`"`).join(`'`); } }); addDataProvider('visits', this.visitData); } /** * It takes a path and returns a path with a leading slash * @param {string} path - The path to be adjusted. * @returns The path with the root removed. */ adjustRootViewUrls(path) { let stripped = this.root && hasBasename(path, this.root) ? path.slice(this.root.length) : path; if (isFilename(this.root)) { return '#' + addLeadingSlash(stripped); } return addLeadingSlash(stripped); } /** * If the current location is the root, or if the current location is the root, return true * @returns The pathname of the current location. */ atRoot() { var _a; return (((_a = this.location) === null || _a === void 0 ? void 0 : _a.pathname) == this.root || this.location.pathname == '/'); } /** * It initializes the router by setting the startUrl, replacing the current route with the startUrl * if the startUrl is at the root, capturing inner links, notifying the listener that the router has * been initialized, and calling allRoutesComplete if all routes are complete * @param {string} [startUrl] - The URL that the router should start at. */ initialize(startUrl) { this.startUrl = startUrl; if (startUrl && this.atRoot()) this.replaceWithRoute(stripBasename(startUrl, this.root)); this.captureInnerLinks(this.win.document.body); this.listener.notifyRouterInitialized(); if (this.routes.every(r => r.completed)) { this.allRoutesComplete(); } } /** * If the route is not found, set the page title to "Not found" and the robots meta tag to "nofollow" */ allRoutesComplete() { //if (!this.hasExactRoute()) { // this.setPageTags({ // title: 'Not found', // robots: 'nofollow', // }) //} this.listener.notifyRouteFinalized(this.location); } /** * If all routes are completed, then call the allRoutesComplete function */ routeCompleted() { if (this.routes.every(r => r.completed)) { this.allRoutesComplete(); } } /** * If the current route has a next route, go to that route. Otherwise, go back in the browser history * @returns the previous location pathname. */ async goBack() { if (this.exactRoute) { const nextRoute = await this.exactRoute.getNextRoute(); if (nextRoute) { this.goToRoute(nextRoute === null || nextRoute === void 0 ? void 0 : nextRoute.path); return; } } this.listener.notifyRouteChangeStarted(this.history.previousLocation.pathname); this.history.goBack(); } /** * If the current route is valid, then go to the next route, otherwise go to the parent route * @returns The next route */ async goNext() { if (this.exactRoute) { if (!this.exactRoute.isValidForNext()) return; const nextRoute = await this.exactRoute.getNextRoute(); // if the route returns null, then we can't move due to validation if (nextRoute) { this.goToRoute(nextRoute.path); return; } } this.goToParentRoute(); } /** * If the current route has a parent route, go to that parent route. Otherwise, go to the parent * route of the current route * @returns The parent route of the current route. */ goToParentRoute() { var _a; if (this.exactRoute) { const parentRoute = this.exactRoute.getParentRoute(); if (parentRoute) { this.goToRoute(parentRoute.path); return; } } const parentSegments = (_a = this.history.location.pathParts) === null || _a === void 0 ? void 0 : _a.slice(0, -1); if (parentSegments) { this.goToRoute(addLeadingSlash(parentSegments.join('/'))); } else { this.goToRoute(this.startUrl || '/'); } } /** * If the history has a stored scroll position, scroll to that position. Otherwise, scroll to the top * of the page * @param {number} scrollOffset - number */ scrollTo(scrollOffset) { // Okay, the frame has passed. Go ahead and render now this.writeTask(() => { var _a; // first check if we have a stored scroll location if (Array.isArray((_a = this.history.location) === null || _a === void 0 ? void 0 : _a.scrollPosition)) { this.win.scrollTo(this.history.location.scrollPosition[0], this.history.location.scrollPosition[1]); return; } this.win.scrollTo(0, scrollOffset || 0); }); } /** * It takes an id, finds the element with that id, and scrolls it into view * @param {string} id - The id of the element to scroll to. */ scrollToId(id) { this.writeTask(() => { const elm = this.win.document.querySelector('#' + id); elm === null || elm === void 0 ? void 0 : elm.scrollIntoView(); }); } /** * It takes a path, resolves it, and pushes it to the history * @param {string} path - The path to navigate to. */ goToRoute(path) { this.listener.notifyRouteChangeStarted(path); const pathName = resolvePathname(path, this.location.pathname); this.history.push(pathName); } /** * It replaces the current route with the new route * @param {string} path - The path to navigate to. */ replaceWithRoute(path) { this.listener.notifyRouteChangeStarted(path); const pathName = resolvePathname(path, this.location.pathname); this.history.replace(pathName); } /** * `matchPath` is a function that returns a `MatchResults` object if the current location matches the * given `options` and `route` (if given) * @param {MatchOptions} options - MatchOptions = {} * @param {Route | null} [route=null] - The route to match against. * @param {boolean} [sendEvent=true] - boolean = true * @returns A MatchResults object. */ matchPath(options = {}, route = null, sendEvent = true) { const match = matchPath(this.location, options); if (route && match && sendEvent) { if (match.isExact) this.listener.notifyMatchExact(route, match); else this.listener.notifyMatch(route, match); } return match; } /** * It takes a path and a parent path, and returns a path that is relative to the parent path * @param {string} path - The path to resolve. * @param {string} [parentPath] - The path of the parent route. * @returns The pathname of the current location. */ resolvePathname(path, parentPath) { return resolvePathname(path, parentPath || this.location.pathname); } /** * If the childUrl is a relative path, then it will be appended to the parentUrl * @param {string} childUrl - The URL of the child route. * @param {string} parentUrl - The URL of the parent route. * @returns The childUrl with the parentUrl as the basename. */ normalizeChildUrl(childUrl, parentUrl) { return ensureBasename(childUrl, parentUrl); } /** * If the event is modified, return true. * @param {MouseEvent} ev - MouseEvent - The event object that was passed to the event handler. * @returns A boolean value. */ isModifiedEvent(ev) { return ev.metaKey || ev.altKey || ev.ctrlKey || ev.shiftKey; } /** * It sets the page title, description, keywords, and robots meta tags, and scrolls to the top of the * page * @param {PageData} pageData - PageData */ async setPageTags(pageData) { var _a, _b, _c; const { title, description, keywords, robots } = pageData; if (this.win.document) { this.win.document.title = [title, this.appTitle] .filter(v => v) .join(' | '); if (robots) this.win.document .querySelectorAll('meta[name*=bot]') .forEach((element) => { const metaTag = element; metaTag.content = robots; }); const canonicalLink = this.win.document.querySelector('link[rel=canonical]'); if (canonicalLink) { const { protocol, host } = document.location; const { pathname } = this.location; canonicalLink.href = `${protocol}//${host}${pathname}`; } this.win.document .querySelectorAll('meta[name*=description]') .forEach((element) => { const metaTag = element; metaTag.content = description || this.appDescription || ''; }); this.win.document .querySelectorAll('meta[name*=keywords]') .forEach((element) => { const metaTag = element; metaTag.content = keywords || this.appKeywords || ''; }); // If the only change to location is a hash change then do not scroll. if ((_b = (_a = this.history) === null || _a === void 0 ? void 0 : _a.location) === null || _b === void 0 ? void 0 : _b.hash) { this.scrollToId(this.history.location.hash.slice(1)); } else { this.scrollTo(((_c = this.exactRoute) === null || _c === void 0 ? void 0 : _c.scrollTopOffset) || this.scrollTopOffset); } } } /** * It captures all the links in the root element and calls the `handleRouteLinkClick` function when a * link is clicked * @param {HTMLElement} root - HTMLElement - The root element to search for links in. * @param {string} [fromPath] - The path from which the link was clicked. */ captureInnerLinks(root, fromPath) { captureElementsEventOnce(root, `a[href]`, 'click', (el, ev) => { if (this.isModifiedEvent(ev) || !(this === null || this === void 0 ? void 0 : this.history)) return true; if (!el.href.includes(location.origin) || el.target) return true; ev.preventDefault(); const path = el.href.replace(location.origin, ''); return this.handleRouteLinkClick(path, fromPath || this.location.pathname); }); } /** * It returns all the routes that have a match property that is exact * @returns The exact routes */ get exactRoutes() { return this.routes.filter(r => { var _a; return (_a = r.match) === null || _a === void 0 ? void 0 : _a.isExact; }); } /** * It returns an array of all the routes that have a match property that is true * @returns An array of all the routes that matched the current path. */ get matchedRoutes() { return this.routes.filter(r => r.match); } /** * If the length of the routes array is greater than 0, return true. Otherwise, return false * @returns The length of the routes array. */ get hasRoutes() { return this.routes.length > 0; } /** * It returns true if the exactRoutes array has at least one item in it * @returns The length of the array of exact routes. */ hasExactRoute() { var _a; return ((_a = this.exactRoutes) === null || _a === void 0 ? void 0 : _a.length) > 0; } /** * If the route has an exact route, return the first exact route. Otherwise, return null * @returns The first exact route in the array of exact routes. */ get exactRoute() { if (this.hasExactRoute()) return this.exactRoutes[0]; return null; } /** * If the route is an absolute path, then go to that route. If the route is a relative path, then * normalize the route and go to that route * @param {string} toPath - The path to navigate to. * @param {string} [fromPath] - The current path. * @returns the route. */ handleRouteLinkClick(toPath, fromPath) { var _a, _b; const route = isAbsolute(toPath) ? toPath : this.normalizeChildUrl(toPath, fromPath || '/'); if (fromPath && route.startsWith(fromPath) && route.includes('#')) { const elId = toPath.substr(toPath.indexOf('#')); (_b = (_a = this.win.document) === null || _a === void 0 ? void 0 : _a.querySelector(elId)) === null || _b === void 0 ? void 0 : _b.scrollIntoView({ behavior: 'smooth', }); return; } this.goToRoute(route); } /** * It removes the event listener, destroys the history object, and destroys each route */ destroy() { this.removeHandler(); this.listener.destroy(); this.history.destroy(); this.routes.forEach(r => r.destroy()); } /** * It creates a new Route object and adds it to the list of routes * @param {HTMLNViewElement | HTMLNViewPromptElement} routeElement - The element that is being used * to create the route. * @param {HTMLNViewElement | null} parentElement - The parent route element. * @param matchSetter - (m: MatchResults | null) => void * @returns A new Route object. */ createRoute(routeElement, parentElement, matchSetter) { let { path, exact, pageTitle, pageDescription, pageKeywords, pageRobots, transition, scrollTopOffset, } = routeElement; const parent = (parentElement === null || parentElement === void 0 ? void 0 : parentElement.route) || null; if (parent) { path = this.normalizeChildUrl(routeElement.path, parent.path); transition = transition || (parent === null || parent === void 0 ? void 0 : parent.transition) || transition; } else { path = this.adjustRootViewUrls(routeElement.path); } routeElement.path = path; if (this.routes.find(r => r.path == path)) { warn(`route: duplicate route detected for ${path}.`); } const route = new Route(this, routeElement, path, parent, exact, { title: pageTitle || (parent === null || parent === void 0 ? void 0 : parent.pageData.title), description: pageDescription, keywords: pageKeywords, robots: pageRobots || (parent === null || parent === void 0 ? void 0 : parent.pageData.robots), }, transition || this.transition, scrollTopOffset, matchSetter, () => { this.routes = this.routes.filter(r => r == route); }); return route; } }