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.
980 lines (857 loc) • 46 kB
JavaScript
// @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-2026 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.
*
* 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.
*
* @typedef {object} routeMenu Single navigation menu configuration
* @property {string} id REQUIRED. Unique (to page) ID. Used as the menu container
* @property {"horizontal"|"vertical"} [menuType] OPTIONAL. Type of menu to create. Default is "horizontal", "vertical" is not yet supported
* @property {string} [label] OPTIONAL. Text to use as an accessible label for the nav element
*
* UibRouterConfig
* @typedef {object} UibRouterConfig UiBRouter router configuration
* @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.
* @property {routeMenu[]} [routeMenus] OPTIONAL, default=none. If present, router will create navigation menus for each entry defined in this array.
*
*/
class UibRouter {
// #region --- Variables ---
/** Class version */
static version = '7.7.4-src'
/** 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
// 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()
if (this.config.routeMenus) this.createMenus(this.config.routeMenus)
// 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
* @throws {Error} if the route container could not be set
*/
_setRouteContainer() {
// Add a default route container if needed
if (!this.config.routeContainer) {
this.config.routeContainer = '#uibdefaultroutecontainer'
console.warn('[uibrouter:constructor] No route container defined in config, using default: `#uibdefaultroutecontainer`')
}
// Get reference to route container or create it
const routeContainerEl = this.routeContainerEl = document.querySelector(this.config.routeContainer)
if (!routeContainerEl) {
// If using the default container, create and attach to the body
if (this.config.routeContainer === '#uibdefaultroutecontainer') {
const tempContainer = document.createElement('div')
tempContainer.setAttribute('id', this.config.routeContainer.replace('#', ''))
document.body.append(tempContainer)
console.warn(`[uibrouter:_setRouteContainer] Route container element with CSS selector '${this.config.routeContainer}' not found in HTML. Created a new element attached to body.`)
} else {
throw new Error(`[uibrouter] Route container element with CSS selector '${this.config.routeContainer}' not found in HTML. Cannot proceed.`)
}
}
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')
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>` // eslint-disable-line no-undef
},
}
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 --- ----- --
/** Create requested navigation menus
* @param {Array<routeMenu>} menus Array of menu definitions. Each entry is a routeMenu object
*/
createMenus(menus) {
if (!Array.isArray(menus) || menus.length < 1) {
console.warn('[uibrouter:createMenus] No valid routeMenus array provided or is empty')
return
}
menus.forEach((menu) => {
if (!menu?.id) {
console.warn(`[uibrouter:createMenus] Invalid menu definition: ${JSON.stringify(menu)}`)
return
}
// Get a reference to the menu container or exit (don't throw)
const menuContainer = document.getElementById(menu.id)
if (!menuContainer) {
console.warn(`[uibrouter:createMenus] Menu container with id '${menu.id}' not found.`)
return
}
menuContainer.style.position = 'relative'
// Clear out the content of the menuContainer
menuContainer.innerHTML = ''
// TODO:
// - Set aria references using reflection. https://developer.mozilla.org/en-US/docs/Web/API/Element#instance_properties_reflected_from_aria_element_references
// - Vertical menus
// - menu icon & tooltip
// - entry icons and tooltips (from description)
// Create a new nav element
const navEl = document.createElement('nav')
if (menu?.label) navEl.setAttribute('aria-label', menu?.label)
// Add the "horizontal" (default) or "vertical" class to navEl
if (menu?.menuType !== 'vertical') navEl.classList.add('horizontal')
else navEl.classList.add('vertical')
// Button to togggle the menu (only shown on small screens & horizontal menus)
const btnEl = document.createElement('button')
btnEl.classList.add('menu-toggle')
btnEl.innerHTML = `
<svg viewBox="0 0 0.8 0.8" xmlns="http://www.w3.org/2000/svg">
<path d="M0.1 0.15h0.6a0.05 0.05 0 0 1 0 0.1H0.1a0.05 0.05 0 1 1 0 -0.1m0 0.2h0.6a0.05 0.05 0 0 1 0 0.1H0.1a0.05 0.05 0 1 1 0 -0.1m0 0.2h0.6a0.05 0.05 0 0 1 0 0.1H0.1a0.05 0.05 0 0 1 0 -0.1"/>
</svg>
`
// ariaControls => ulEl
navEl.appendChild(btnEl)
// List to contain the menu items
const ulEl = document.createElement('ul')
ulEl.classList.add('routemenu')
ulEl.setAttribute('role', 'menubar')
this.config.routes.forEach((route) => {
if (!route?.id) return // No route id, skip this one
// Create a list item for the route
const liEl = document.createElement('li')
liEl.setAttribute('role', 'none') // No role for the list item
// Create a link for the route
const aEl = document.createElement('a')
aEl.setAttribute('role', 'menuitem') // Set the role for the link
aEl.setAttribute('href', `#${route.id}`) // Set the href
aEl.setAttribute('data-route', route.id) // Set the data-route attribute
aEl.innerText = route?.title || route.id // Use the title or id as the link text
liEl.appendChild(aEl)
ulEl.appendChild(liEl)
})
navEl.appendChild(ulEl)
menuContainer.appendChild(navEl)
// Update the currently selected menu item
this.setCurrentMenuItems()
// Open menu on nav click if hamburger is visible and menu is closed
navEl.addEventListener('mouseup', (e) => {
if (window.innerWidth > 600) return
if (ulEl.contains(e.target)) return // Just let normal routing work
toggleMenu()
})
// Close menu on resize above 600px
window.addEventListener('resize', () => {
if (window.innerWidth > 600) {
closeMenu()
}
})
/** toggle the menu */
function toggleMenu() {
if (navEl.getAttribute('aria-expanded') === 'true') {
closeMenu()
} else {
setTimeout(() => {
navEl.setAttribute('aria-expanded', true)
btnEl.setAttribute('aria-expanded', true)
document.addEventListener('mouseup', closeMenu)
}, 0)
}
}
/** Close the menu */
function closeMenu() {
navEl.setAttribute('aria-expanded', false)
btnEl.setAttribute('aria-expanded', false)
document.addEventListener('mouseup', closeMenu)
}
})
}
/** 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
* @throws {Error} If the safety protocol is triggered (too many route bounces) or if no valid route found
*/
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')
// 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')
// 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
* @throws {Error} If no extOther provided or if fetch fails
*/
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) {
console.warn(`[uibrouter:loadOther] Parent container '${f.container}' not found for '${f.id}'`)
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)
// debugger
// Have to re-apply the scripts to make them run - only for external templates
// if (this.isRouteExternal(routeId)) this._applyScripts(docFrag)
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) {
const routeIds = [...this.routeIds]
if (returnHash === true) {
return routeIds.map((r) => {
return 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()
// re-create the auto-menus
if (this.config.routeMenus) this.createMenus(this.config.routeMenus)
// Let everyone know it all finished
document.dispatchEvent(new CustomEvent('uibrouter:routes-added', { detail: routeDefn, }))
if (this.uibuilder) uibuilder.set('uibrouter', 'routes added')
// If asked to, load all the new external templates now - otherwise loaded on route change
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 ---
/** Mark/unmark menu items to highlight the currently shown route */
setCurrentMenuItems() {
const items = document.querySelectorAll('li[data-route], a[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')
}
})
}
/** Return the title of the current route
* @returns {string} Current route title
*/
routeTitle() {
const thisRoute = this.currentRoute() || {}
return thisRoute?.title || thisRoute?.id || '[ROUTE NOT FOUND]'
}
/** Return the description of the current route
* @returns {string} Current route description
*/
routeDescription() {
const thisRoute = this.currentRoute() || {}
return thisRoute.description || thisRoute.id || '[ROUTE NOT FOUND]'
}
/** Return the current route configuration
* @returns {object} Current route configuration
*/
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?')
}