node-red-contrib-uibuilder
Version:
Easily create data-driven web UI's for Node-RED. Single- & Multi-page. Multiple UI's. Work with existing web development workflows or mix and match with no-code/low-code features.
820 lines (712 loc) • 38.9 kB
JavaScript
/* eslint-disable no-undef, jsdoc/check-property-names */
// @ts-nocheck
/** A simple, vanilla JavaScript front-end router class
* Included in node-red-contrib-uibuilder but is not dependent on it.
* May be used in other contexts as desired.
*
* Copyright (c) 2023-2024 Julian Knight (Totally Information)
* https://it.knightnet.org.uk
*
* Licensed under the Apache License, Version 2.0 (the 'License');
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an 'AS IS' BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/** Type definitions
* routeDefinition
* @typedef {object} routeDefinition Single route configuration
* @property {string} id REQUIRED. Route ID
* @property {string} src REQUIRED for external, optional for internal (default=route id). CSS Selector for template tag routes, url for external routes
* @property {"url"|undefined} [type] OPTIONAL, default=internal route. "url" for external routes
* @property {string} [title] OPTIONAL, default=route id. Text to use as a short title for the route
* @property {string} [description] OPTIONAL, default=route id. Text to use as a long description for the route
* @property {"html"|"md"|"markdown"} [format] OPTIONAL, default=html. Route content format, HTML or Markdown (md). Markdown requires the Markdown-IT library to have been loaded.
*
* UibRouterConfig
* @typedef {object} UibRouterConfig Configuration for the UiBRouter class instances
* @property {routeDefinition[]} routes REQUIRED. Array of route definitions
* @property {Array<string|object>} [mdPlugins] OPTIONAL. Array of Markdown-IT plugins
* @property {string} [defaultRoute] OPTIONAL, default=1st route. If set to a route id, that route will be automatically shown on load
* @property {string} [routeContainer] OPTIONAL, default='#uibroutecontainer'. CSS Selector for an HTML Element containing routes
* @property {boolean} [hide] OPTIONAL, default=false. If TRUE, routes will be hidden/shown on change instead of removed/added
* @property {boolean} [templateLoadAll] OPTIONAL, default=false. If TRUE, all external route templates will be loaded when the router is instanciated. Default is to lazy-load external templates
* @property {boolean} [templateUnload] OPTIONAL, default=true. If TRUE, route templates will be unloaded from DOM after access.
* @property {otherLoadDefinition[]} [otherLoad] OPTIONAL, default=none. If present, router start will pre-load other external templates direct to the DOM. Use for menu's, etc.
*
* otherLoadDefinition
* @typedef {object} otherLoadDefinition Single external load configuration
* @property {string} id REQUIRED. Unique (to page) ID. Will be applied to loaded content.
* @property {string} src REQUIRED. url of external template to load
* @property {string} container REQUIRED. CSS Selector defining the parent element that this will become the child of. If it doesn't exist on page, content will not be loaded.
*/
class UibRouter { // eslint-disable-line no-unused-vars
//#region --- Variables ---
/** Class version */
static version = '1.4.0' // 2024-04-07
/** Ensures only 1 class instance on a page */
static #instanceExists = false
/** Options for Markdown-IT if available (set in constructor) */
static mdOpts
/** Reference to pre-loaded Markdown-IT library */
static md
/** Configuration settings @type {UibRouterConfig} */
config
/** Reference to the container DOM element - set in setup() @type {HTMLDivElement} */
routeContainerEl
/** The current route id after doRoute() has been called */
currentRouteId
/** The previous route id after doRoute() has been called */
previousRouteId
/** Array of route ID's (created in constructor) */
routeIds = []
/** Internal only. Set to true when the _start() method has been called */
#startDone = false
safety = 0
uibuilder = false
//#endregion --- ----- ---
//#region --- Internal Methods ---
/** Class constructor
* @param {UibRouterConfig} routerConfig Configuration object
*/
constructor(routerConfig) {
// Enforce only 1 instance on page (otherwise uibuilder vars will be overwritten)
if (UibRouter.#instanceExists) throw new Error('[uibrouter:constructor] Only 1 instance of a UibRouter may exist on the page.')
// Fetch is on desktop browsers since 2017 at least. Not so much on mobile (Android!)
// May need a polyfill on mobile or old browsers.
if (!fetch) throw new Error('[uibrouter:constructor] UibRouter requires `fetch`. Please use a current browser or load a fetch polyfill.')
if (!routerConfig) throw new Error('[uibrouter:constructor] No config provided')
if (!routerConfig.routes) throw new Error('[uibrouter:constructor] No routes provided in routerConfig')
// Save the config
this.config = routerConfig
// Add a default route container uf needed
if (!this.config.routeContainer) this.config.routeContainer = '#uibroutecontainer'
// If no default set in config, set to the first entry
if (!this.config.defaultRoute && this.config.routes[0] && this.config.routes[0].id) this.config.defaultRoute = this.config.routes[0].id
// Other defaults
if (!this.config.hide) this.config.hide = false
if (!this.config.templateLoadAll) this.config.templateLoadAll = false
if (!this.config.templateUnload) this.config.templateUnload = true
this._normaliseRouteDefns(this.config.routes)
// If Markdown-IT library pre-loaded, set it up now
if (window['markdownit']) this._markdownIt()
if (window['uibuilder']) {
this.uibuilder = true
uibuilder.set('uibrouterinstance', this)
}
// Create/access the route container element, sets this.routeContainerEl
this._setRouteContainer()
if (this.config.otherLoad) this.loadOther(this.config.otherLoad)
this._updateRouteIds()
// Only pre-load all templates if requested (default is not to)
if (this.config.templateLoadAll === false) {
this._start()
} else {
console.info('[uibrouter] Pre-loading all external templates')
// Load all external route templates async in parallel - NB: Object.values works on both arrays and objects
// Note that final `then` is called even if no external routes are given
Promise.allSettled(Object.values(routerConfig.routes).filter(r => r.type && r.type === 'url').map(this._loadExternal))
.then( results => {
results.filter( res => res.status === 'rejected').forEach(res => {
console.error(res.reason)
})
results.filter( res => res.status === 'fulfilled').forEach(res => {
console.log('allSettled results', res, results)
this._appendExternalTemplates(res.value)
})
// Everything is loaded that can be so we can start
this._start()
return true
})
.catch( reason => {
console.error(reason)
})
}
UibRouter.#instanceExists = true
}
/** Save a reference to, and create if necessary, the HTML element to hold routes */
_setRouteContainer() {
const body = document.getElementsByTagName('body')[0]
// Get reference to route container or create it
let routeContainerEl = this.routeContainerEl = document.querySelector(this.config.routeContainer)
if (!routeContainerEl) {
// throw new Error(`Route container element with CSS selector '${routerConfig.routeContainer}' not found in HTML`)
const tempContainer = document.createElement('div')
tempContainer.setAttribute('id', this.config.routeContainer.replace('#', ''))
body.append(tempContainer)
routeContainerEl = this.routeContainerEl = document.querySelector(this.config.routeContainer)
}
}
/** Apply fetched external elements to templates tags under the head tag
* @param {HTMLElement[]} loadedElements Array of loaded external elements to add as templates to the head tag
* @returns {number} Count of load errors
*/
_appendExternalTemplates(loadedElements) {
if (!Array.isArray(loadedElements)) loadedElements = [loadedElements]
// console.log('_appendExternalTemplates', loadedElements)
const head = document.getElementsByTagName('head')[0]
let errors = 0
// Append the loaded content to the main container
loadedElements.forEach(element => {
if (Array.isArray(element)) {
console.error(...element)
errors++
} else {
head.append(element)
}
})
return errors
}
/** Called once all external templates have been loaded */
async _start() {
if (this.#startDone === true) return // Don't run this again
// Go to url hash route or default route if no route in url
await this.doRoute(this.keepHashFromUrl(window.location.hash))
// After initial route set, listen for url hash changes and process route change
window.addEventListener('hashchange', (event) => this._hashChange(event) )
// Events on fully loaded ...
document.dispatchEvent(new CustomEvent('uibrouter:loaded'))
if (this.uibuilder) uibuilder.set('uibrouter', 'loaded') // eslint-disable-line no-undef
this.#startDone = true // Don't run this again
}
/** Called when the URL Hash changes
* @param {HashChangeEvent} event URL Hash change event object
*/
_hashChange(event) {
// console.log(`[uibrouter] hashchange: ${this.keepHashFromUrl(event.oldURL)} => ${this.keepHashFromUrl(event.newURL)}` )
this.doRoute(event)
}
/** Loads an external HTML file into a `<template>` tag, adding the router id as the template id. Or throws.
* @param {routeDefinition} routeDefinition Configuration for a single route
* @returns {HTMLTemplateElement[]} An HTMLTemplateElement that will provide the route content
*/
async _loadExternal(routeDefinition) {
if (!routeDefinition) throw new Error('[uibrouter:loadExternal] Error loading route template. No route definition provided.')
// Obviously, this only works for internal routes
if (!routeDefinition.src) {
if (!routeDefinition.type || (routeDefinition.type && routeDefinition.type !== 'url')) routeDefinition.src = routeDefinition.id
else throw new Error('[uibrouter:loadExternal] Error loading route template. `src` property not defined')
}
const id = routeDefinition.id
let response
try {
response = await fetch(routeDefinition.src)
} catch (e) {
throw new Error(`[uibrouter:loadExternal] Error loading route template HTML for route: ${routeDefinition.id}, src: ${routeDefinition.src}. Error: ${e.message}`, e)
}
// Fetch failed?
if (response.ok === false) throw new Error(`[uibrouter:loadExternal] Fetch failed to return data for route: ${routeDefinition.id}, src: ${routeDefinition.src}. Status: ${response.statusText} (${response.status})`, [routeDefinition.id, routeDefinition.src, response.status, response.statusText])
/** @type {string & any[]} */
let htmlText = await response.text()
// If Markdown & library loaded, convert from markdown to HTML
if (window['markdownit'] && routeDefinition.format === 'md') {
htmlText = this.renderMarkdown(htmlText)
}
// Check to see if template already exists, if so, remove it
try {
const chkTemplate = document.querySelector(`#${id}`)
if (chkTemplate) chkTemplate.remove()
} catch (e) {}
// Return the template
const tempContainer = document.createElement('template')
tempContainer.innerHTML = htmlText
tempContainer.setAttribute('id', id)
return tempContainer
}
/** Remove/re-apply scripts in a container Element so that they are executed.
* @param {HTMLElement} tempContainer HTML Element of container to process
*/
_applyScripts(tempContainer) {
const scripts = tempContainer.querySelectorAll('script')
scripts.forEach( scr => {
const newScript = document.createElement('script')
newScript.textContent = scr.innerText
tempContainer.append(newScript)
scr.remove() // remove the origin
})
}
/** Set up the MarkdownIT library if loaded */
_markdownIt() {
if (!window['markdownit']) return
// If plugins not yet defined, check if uibuilder has set them
if (!this.config.mdPlugins && window['uibuilder'] && window['uibuilder'].ui_md_plugins) this.config.mdPlugins = window['uibuilder'].ui_md_plugins
// If Markdown-IT library pre-loaded, set it up now
UibRouter.mdOpts = {
html: true,
xhtmlOut: false,
linkify: true,
_highlight: true,
_strict: false,
_view: 'html',
langPrefix: 'language-',
// NB: the highlightjs (hljs) library must be loaded before markdown-it for this to work
highlight: function(str, lang) {
if (window['hljs']) {
if (lang && window['hljs'].getLanguage(lang)) {
try {
return `<pre><code class="hljs border language-${lang}" data-language="${lang}" title="Source language: '${lang}'">${window['hljs'].highlight(str, { language: lang, ignoreIllegals: true }).value}</code></pre>`
} finally { } // eslint-disable-line no-empty
} else {
try {
const high = window['hljs'].highlightAuto(str)
return `<pre><code class="hljs border language-${high.language}" data-language="${high.language}" title="Source language estimated by HighlightJS: '${high.language}'">${high.value}</code></pre>`
} finally { } // eslint-disable-line no-empty
}
}
return `<pre><code class="border">${Ui.md.utils.escapeHtml(str).trim()}</code></pre>`
},
}
UibRouter.md = window['markdownit'](UibRouter.mdOpts)
if (this.config.mdPlugins) {
if (!Array.isArray(this.config.mdPlugins)) {
console.error('[uibrouter:_markDownIt:plugins] Could not load plugins, config.mdPlugins is not an array')
return
}
this.config.mdPlugins.forEach( plugin => {
if (typeof plugin === 'string') {
UibRouter.md.use(window[plugin])
} else {
const name = Object.keys(plugin)[0]
UibRouter.md.use(window[name], plugin[name])
}
})
}
}
/** Normalise route definition arrays
* @param {Array<routeDefinition>} routeDefns Route definitions to normalise
*/
_normaliseRouteDefns(routeDefns) {
if (!Array.isArray(routeDefns)) routeDefns = [routeDefns]
routeDefns.forEach( defn => {
let fmt = defn.format || 'html'
fmt = fmt.toLowerCase()
if (fmt === 'markdown') fmt = 'md'
defn.format = fmt
})
}
/** Update this.routeIds array from this.config (on start and after add/remove routes) */
_updateRouteIds() {
this.routeIds = new Set(Object.values(this.config.routes).map( r => r.id ))
}
/** If uibuilder in use, report on route change
* @param {string} newRouteId The route id now shown
*/
_uibRouteChange(newRouteId) {
if (!this.uibuilder || !newRouteId) return
uibuilder.set('uibrouter', 'route changed')
uibuilder.set('uibrouter_CurrentRoute', newRouteId)
uibuilder.set('uibrouter_CurrentTitle', this.routeTitle())
uibuilder.set('uibrouter_CurrentDescription', this.routeDescription())
uibuilder.set('uibrouter_CurrentDetails', this.getRouteConfigById(newRouteId))
// Send control msg back to Node-RED
uibuilder.sendCtrl({
uibuilderCtrl: 'route change',
routeId: newRouteId,
title: this.routeTitle(),
description: this.routeDescription(),
details: this.getRouteConfigById(newRouteId),
})
}
//#endregion --- ----- --
/** Process a routing request
* All errors throw so make sure to try/catch calls to this method.
* @param {PointerEvent|MouseEvent|HashChangeEvent|TouchEvent|string} routeSource Either string containing route id or DOM Event object either click/touch on element containing `href="#routeid"` or Hash URL change event
*/
async doRoute(routeSource) {
if (this.safety > 10) throw new Error('🚫 [uibrouter:doRoute] Safety protocol triggered, too many route bounces')
// If no routes at all, just exit (maybe they will be loaded later)
if (!this.config.routes || this.config.routes < 1) return
if (!routeSource) routeSource = this.config.defaultRoute
const container = this.routeContainerEl
if (!container) throw new Error('[uibrouter:doRoute] Cannot route, has router.setup() been called yet?')
// Remove all the url and query param text and any leading # - returns '' if no current hash
const currentHash = this.keepHashFromUrl(window.location.hash)
// If no route source provided, take the current hash (which might be '' and that will trigger the default route if defined)
if (!routeSource) routeSource = currentHash
let newRouteId, oldRouteId
// Define new and old routes depending on different call types
if (typeof routeSource === 'string') { // Manually provided route id
// console.log(`[uibrouter:doRoute] manual: ${currentHash} => ${this.keepHashFromUrl(routeSource)}. Current: ${currentHash}` )
newRouteId = this.keepHashFromUrl(routeSource)
oldRouteId = currentHash
// If no hash & config has default, set the default as new
if (newRouteId === '' && this.config.defaultRoute) newRouteId = this.config.defaultRoute
// If the new route id not the same as the current one in the url hash, just change the current hash & exit
if (newRouteId !== currentHash ) {
window.location.hash = `#${newRouteId}`
return
}
} else if (routeSource.type === 'hashchange') { // A URL Hash change event
// console.log(`[uibrouter:doRoute] hashchange: ${this.keepHashFromUrl(routeSource.oldURL)} => ${this.keepHashFromUrl(routeSource.newURL)}. Current: ${currentHash}` )
const newUrl = routeSource.newURL
// Check if URL actually contains a #
if (newUrl.includes('#')) {
oldRouteId = this.keepHashFromUrl(routeSource.oldURL)
newRouteId = this.keepHashFromUrl(newUrl) // Only keep anything after the # & ignoring query params
} else return
} else { // A mouse click/touch event on a dom element with an href attribute
oldRouteId = currentHash
// Try to get the route name from the URL hash
try {
newRouteId = this.keepHashFromUrl(routeSource.target.attributes.href.value) // Only keep anything after the # & ignoring query params
} catch (e) {
throw new Error('[uibrouter:doRoute] No valid route found. Event.target does not have an href attribute')
}
}
let routeShown = false
// If no defined valid route id, undo and report error
if (!newRouteId || !this.routeIds.has(newRouteId)) {
// Events on route change fail ...
document.dispatchEvent(new CustomEvent('uibrouter:route-change-failed', { detail: { newRouteId, oldRouteId } }))
if (this.uibuilder) uibuilder.set('uibrouter', 'route change failed') // eslint-disable-line no-undef
// If ID's the same, this happened on load and would keep failing so revert to default
if (newRouteId === oldRouteId) oldRouteId = ''
// Don't throw an error here, it stops the menu highlighting from working
console.error(`[uibrouter:doRoute] No valid route found. Either pass a valid route name or an event from an element having an href of '#${newRouteId}'. Route id requested: '${newRouteId}'`)
this.safety++
// Revert route
this.doRoute(oldRouteId || '')
return
}
// At this point, we have a valid route ID
// NB: The `loadRoute` method will attempt to load external templates that are not currently loaded
// Show the new container (replace or show)
if (this.config.hide) {
// config.hide = true so hide previous contents
if (oldRouteId) {
/** @type {HTMLElement|null} */
const oldContent = document.querySelector(`div[data-route="${oldRouteId}"]`)
if (oldContent) oldContent.style.display = 'none'
}
/** and unhide new route if possible @type {HTMLElement|null} */
const content = document.querySelector(`div[data-route="${newRouteId}"]`)
if (content) {
content.style.removeProperty('display')
routeShown = true
} else {
// else create new content from template
try {
routeShown = await this.loadRoute(newRouteId)
} catch (e) {
console.error('[uibrouter:doRoute] ', e)
routeShown = false
}
}
} else {
// config.hide != true so remove previous contents
container.replaceChildren()
// Create new content from template
try {
routeShown = await this.loadRoute(newRouteId)
} catch (e) {
console.error('[uibrouter:doRoute] ', e)
routeShown = false
}
}
// console.log({ newRouteId, oldRouteId, currentHash, routeShown })
// Roll back the route change if the new route cannot be shown
if (routeShown === false) {
// Events on route change fail ...
document.dispatchEvent(new CustomEvent('uibrouter:route-change-failed', { detail: { newRouteId, oldRouteId } }))
if (this.uibuilder) uibuilder.set('uibrouter', 'route change failed') // eslint-disable-line no-undef
// If ID's the same, this happened on load and would keep failing so revert to default
if (newRouteId === oldRouteId) oldRouteId = ''
// Don't throw an error here, it stops the menu highlighting from working
console.error(`[uibrouter:doRoute] Route content for '${newRouteId}' could not be shown, reverting to old route '${oldRouteId}'`)
this.safety++
// Revert route
this.doRoute(oldRouteId || '')
return
}
// At this point, the new route has successfully been shown
this.safety = 0
// If requested (default), unload the old route template
if (this.config.templateUnload) this.unloadTemplate(oldRouteId)
// Retain current and previous route id's
this.currentRouteId = newRouteId
this.previousRouteId = oldRouteId
// Record the current route on the route container
container.dataset.currentRoute = newRouteId
// Update any existing HTML menu items
this.setCurrentMenuItems()
// Events on route changed ...
document.dispatchEvent(new CustomEvent('uibrouter:route-changed', { detail: { newRouteId, oldRouteId } }))
this._uibRouteChange(newRouteId)
}
/** Load other external files and apply to specific parents (mostly used for externally defined menus)
* @param {otherLoadDefinition|Array<otherLoadDefinition>} extOther Required. Array of objects defining what to load and where
*/
loadOther(extOther) {
if (!extOther) throw new Error('[uibrouter:loadOther] At least 1 load definition must be provided')
if (!Array.isArray(extOther)) extOther = [extOther]
extOther.forEach( async f => {
const parent = document.querySelector(f.container)
if (!parent) return // Nothing to do if parent does not exist on page
let response
try {
response = await fetch(f.src)
} catch (e) {
throw new Error(`[uibrouter:loadOther] Error loading template HTML for '${f.id}', src: '${f.src}'. Error: ${e.message}`, e)
}
// Fetch failed?
if (response.ok === false) throw new Error(`[uibrouter:loadOther] Fetch failed to return data '${f.id}', src: '${f.src}'. Status: ${response.statusText} (${response.status})`, [f.id, f.src, response.status, response.statusText])
/** @type {string & any[]} */
const htmlText = await response.text()
// We fetched it, so now load it to the DOM
const tempContainer = document.createElement('div')
tempContainer.innerHTML = htmlText
tempContainer.id = f.id
parent.append(tempContainer)
this._applyScripts(parent.lastChild)
})
}
/** Async method to create DOM route content from a route template (internal or external) - loads external templates if not already loaded
* Route templates have to be a `<template>` tag with an ID that matches the route id.
* Scripts in the template are run at this point.
* All errors throw so make sure to try/catch calls to this method.
* @param {string} routeId ID of the route definition to use to create the content
* @param {HTMLElement} [routeParentEl] OPTIONAL, default=this.routeContainerEl (master route container). Reference to an HTML Element to which the route content will added as a child.
* @returns {boolean} True if the route content was created successfully, false otherwise
*/
async loadRoute(routeId, routeParentEl) {
if (!routeParentEl) routeParentEl = this.routeContainerEl
// Try to reference the template for this route
let rContent
try {
rContent = await this.ensureTemplate(routeId)
} catch (e) {
throw new Error(`[uibrouter:loadRoute] No template for route id '${routeId}'. \n ${e.message}`)
}
// Clone the template
const docFrag = rContent.content.cloneNode(true)
// Have to re-apply the scripts to make them run - only for external templates
if (this.isRouteExternal(routeId)) this._applyScripts(docFrag)
// Create the route wrapper div with data-route attrib
const tempContainer = document.createElement('div')
tempContainer.dataset.route = routeId
tempContainer.append(docFrag)
// And finally try to append to the container
try {
routeParentEl.append(tempContainer)
} catch (e) {
throw new Error(`[uibrouter:loadRoute] Failed to apply route id '${routeId}'. \n ${e.message}`)
}
// Then tell the world
document.dispatchEvent(new CustomEvent('uibrouter:route-loaded', { routeId: routeId }))
// If we get here, everything is good
return true
}
/** Async method to ensure that a template element exists for a given route id
* If route is external, will try to load if it doesn't exist.
* All errors throw so make sure to try/catch calls to this method.
* @param {string} routeId A single route ID
* @returns {HTMLTemplateElement} A reference to the HTML Template element
*/
async ensureTemplate(routeId) {
if (!routeId || !this.routeIds.has(routeId)) throw new Error(`[uibrouter:ensureTemplate] No valid route id provided. Route ID: '${routeId}'`)
// Try to reference the template for this route
let rContent = document.querySelector(`#${routeId}`)
// If not found, try once to load it - assuming it is external
if (!rContent) {
// If external template content doesn't exist, try to load it now (but only try once)
const r = this.getRouteConfigById(routeId)
if (r.type && r.type === 'url') {
let loadedEls
try {
loadedEls = await this._loadExternal(r)
} catch (e) {
throw new Error(e.message, e)
}
if (!loadedEls) throw new Error(`[uibrouter:ensureTemplate] No route template found for route selector '#${routeId}'. Does the link url match a defined route id?`)
// Apply fetched external elements to templates tags under the head tag
this._appendExternalTemplates(loadedEls)
// And check that the template now actually exists
rContent = document.querySelector(`#${routeId}`)
if (!rContent) throw new Error(`[uibrouter:ensureTemplate] No valid route template found for external route selector '#${routeId}'`)
} else {
// type not not external so we can't do anything when it doesn't actually exist
throw new Error(`[uibrouter:ensureTemplate] No route template found for internal route selector '#${routeId}'. Ensure that a template element with the matching ID exists in the HTML.`)
}
}
return rContent
}
/** Return a route config given a route id (returns undefined if route not found)
* @param {string} routeId Route ID to search for
* @returns {routeDefinition|undefined} Route config for found id else undefined
*/
getRouteConfigById(routeId) {
return Object.values(this.config.routes).filter(r => r.id === routeId)[0]
}
/** Return true if the given route is external, false otherwise
* Used to correctly (re)apply script tags when cloning the template to the DOM (createRouteContent)
* @param {string} routeId Route ID to check
* @returns {boolean} True if the given route is external, false otherwise
*/
isRouteExternal(routeId) {
const routeConfig = this.getRouteConfigById(routeId)
return !!(routeConfig && routeConfig.type === 'url')
}
/** Go to the default route if it has been specified */
defaultRoute() {
if (this.config.defaultRoute) this.doRoute(this.config.defaultRoute)
}
/** Remove the hash from the browser URL */
removeHash() {
history.pushState('', document.title, window.location.pathname + window.location.search)
}
/** Empty the current container and remove url hash - does not trigger a route change */
noRoute() {
this.removeHash()
this.routeContainerEl.replaceChildren()
}
/** Only keep anything after the # & ignoring query params
* @param {string} url URL to extract the hash from
* @returns {string} Just the route id
*/
keepHashFromUrl(url) {
if (!url) return ''
return url.replace(/^.*#(.*)/, '$1').replace(/\?.*$/, '')
}
/** Return an array of route ids (to facilitate creation of menus)
* @param {boolean} returnHash If true, returns id's with leading `#` to apply to href attributes else returns the id
* @returns {string[]} Array of route id's or route url hashes
*/
routeList(returnHash) {
if (returnHash === true) return this.routeIds.map((r) => returnHash === true ? `#${r.id}` : r.id)
return this.routeIds
}
/** Add new route definitions to the existing ones
* @param {routeDefinition|routeDefinition[]} routeDefn Single or array of route definitions to add
*/
addRoutes(routeDefn) {
if (!Array.isArray(routeDefn)) routeDefn = [routeDefn]
this._normaliseRouteDefns(routeDefn)
// Update the route config
this.config.routes.push(...routeDefn)
// and update the routeIds list
this._updateRouteIds()
// Let everyone know it all finished
document.dispatchEvent(new CustomEvent('uibrouter:routes-added', { detail: routeDefn }))
if (this.uibuilder) uibuilder.set('uibrouter', 'routes added')
if (this.config.templateLoadAll) {
// Load all external route templates async in parallel - NB: Object.values works on both arrays and objects
Promise.allSettled(Object.values(routeDefn).filter(r => r.type && r.type === 'url').map(this._loadExternal))
.then( results => {
results.filter( res => res.status === 'rejected').forEach(res => {
console.error(res.reason)
})
// results.filter( res => res.status === 'fulfilled').forEach(res => {})
// Everything is loaded that can be - Add new routes to config
this.config.routes.push(...routeDefn)
// and update the routeIds list
this._updateRouteIds()
// Let everyone know it all finished
document.dispatchEvent(new CustomEvent('uibrouter:routes-added', { detail: routeDefn }))
if (this.uibuilder) uibuilder.set('uibrouter', 'routes added')
return true
})
.catch( reason => {
console.error(reason)
})
}
}
/** Remove a template from the DOM (optionally external templates only)
* @param {string} routeId REQUIRED. The route id of the template to remove (templates are ID's by their route id)
* @param {boolean=} externalOnly OPTIONAL, default=true. If true only remove if routeId is an external template
*/
unloadTemplate(routeId, externalOnly) {
if (!externalOnly) externalOnly = true
if (!routeId || !this.isRouteExternal(routeId)) return
if (externalOnly === true && !this.isRouteExternal(routeId)) return
// Try to get the template - if found delete it
const chkTemplate = document.querySelector(`#${routeId}`)
if (chkTemplate) chkTemplate.remove()
}
/** Remove ALL templates from the DOM (optionally external templates only)
* @param {Array<string>=} templateIds OPTIONAL, default=ALL. Array of template (route) id's to remove
* @param {boolean=} externalOnly OPTIONAL, default=true. If true only remove if routeId is an external template
*/
deleteTemplates(templateIds, externalOnly) {
if (!externalOnly) externalOnly = true
if (!templateIds || templateIds === '*') templateIds = [...this.routeIds]
if (!Array.isArray(templateIds)) templateIds = [templateIds]
templateIds.forEach( routeId => {
if (externalOnly === true && !this.isRouteExternal(routeId)) return
this.unloadTemplate(routeId, externalOnly)
} )
}
//#region --- utils for page display & processing ---
setCurrentMenuItems() {
// const items = document.querySelectorAll(`li[data-route="${this.currentRouteId}"]`)
const items = document.querySelectorAll('li[data-route]')
items.forEach( item => {
if (item.dataset.route === this.currentRouteId) {
item.classList.add('currentRoute')
item.setAttribute('aria-current', 'page')
} else {
item.classList.remove('currentRoute')
item.removeAttribute('aria-current')
}
})
}
routeTitle() {
const thisRoute = this.currentRoute() || {}
return thisRoute.title || thisRoute.id || '[ROUTE NOT FOUND]'
}
routeDescription() {
const thisRoute = this.currentRoute() || {}
return thisRoute.description || thisRoute.id || '[ROUTE NOT FOUND]'
}
currentRoute() {
return this.getRouteConfigById(this.currentRouteId)
}
/** Use Markdown-IT to render Markdown to HTML
* https://markdown-it.github.io/markdown-it
* @param {string} mdText Markdown string
* @returns {string|undefined} HTML rendering of the Markdown input
*/
renderMarkdown(mdText) {
if (!window['markdownit']) return
if (!UibRouter.md) this._markdownIt() // In case Markdown-IT lib was late loaded
try {
return UibRouter.md.render(mdText.trim())
} catch (e) {
console.error(`[uibrouter:renderMarkdown] Could not render Markdown. ${e.message}`, e)
return '<p class="border error">Could not render Markdown<p>'
}
}
//#endregion ---- ----- ----
// TODO
// deleteRoutes(aRoutes) {
// // Delete all if no list provided
// if (!aRoutes) aRoutes = this.config.routes
// if (!Array.isArray(aRoutes)) aRoutes = [aRoutes]
// console.log('to be deleted', this.config.routes.filter(r => aRoutes.includes(r.id)))
// console.log('to be retained', this.config.routes.filter(r => !aRoutes.includes(r.id)))
// // TODO actually remove the unwanted route templates
// // TODO remove from the config: this.config.routes = this.config.routes.filter(r => !aRoutes.includes(r.id))
// // ? Optional future upgrade - attempt to also remove any links to this route?
// }
// TODO
// reloadTemplates(templateIds) {
// if (!Array.isArray(templateIds)) templateIds = [templateIds]
// templateIds.forEach( templateid => {
// // TODO reload
// } )
// }
} // ---- End of class ----
// For use in ESM loads
export { UibRouter }
export default UibRouter
// Auto-assign for when the library is loaded via a script tag
if (!window['UibRouter']) {
window['UibRouter'] = UibRouter
} else {
console.warn('`UibRouter` already assigned to window. Have you tried to load it more than once?')
}