UNPKG

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.

1,178 lines (1,056 loc) β€’ 100 kB
/* eslint-disable security/detect-non-literal-regexp */ // @ts-nocheck /* eslint-disable jsdoc/valid-types */ /** Send a dynamic UI config to the uibuilder front-end library. * The FE library will update the UI accordingly. * * Copyright (c) 2025-2026 Julian Knight (Totally Information) * * 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. */ 'use strict' // Save indexes for convenient debugging - not needed for production // if (!globalThis._uibuilder_) globalThis._uibuilder_ = {} // if (!globalThis._uibuilder_.markweb) globalThis._uibuilder_.markweb = {} // if (!globalThis._uibuilder_.markweb.indexes) globalThis._uibuilder_.markweb.indexes = {} /** --- Type Defs - should help with coding --- * @typedef {import('../../typedefs').runtimeRED} runtimeRED * @typedef {import('../../typedefs').runtimeNodeConfig} runtimeNodeConfig * @typedef {import('../../typedefs').runtimeNode} runtimeNode * @typedef {import('../../typedefs').uibMwNode} uibMwNode */ // #region ----- Module level variables ---- // const { basename, dirname, join, isAbsolute, parse, relative, sep, } = require('path') const express = require('express') const { accessSync, copySync, existsSync, fgSync, readdirSync, readFile, readFileSync, stat, } = require('../libs/fs.cjs') const { urlJoin, formatDateIntl, } = require('../libs/tilib.cjs') const { setNodeStatus, } = require('../libs/uiblib.cjs') // Utility library for uibuilder const sockets = require('../libs/socket.cjs') const web = require('../libs/web.cjs') // The uibuilder global configuration object, used throughout all nodes and libraries. const uib = require('../libs/uibGlobalConfig.cjs') // Used to show approx size of variables in bytes: serialize(variableName).byteLength const { serialize, } = require('v8') // Import my utility packages using npm workspaces const { md, mdParse: _mdParseRaw, directivePlugin, fmVariablesPlugin, fm, } = require('../../packages/uib-md-utils') const { chokidar, } = require('../../packages/uib-fs-utils') /** The node context for the current mdParse call. Set before each synchronous md.render() call. * @type {(runtimeNode & uibMwNode)|null} */ let _mdCurrentNode = null /** Wrapper around mdParse that sets the current node context for directive handlers * @param {runtimeNode & uibMwNode} node The node instance to use for directive resolution * @param {string} content The markdown content to render * @param {object} env The environment/attributes object passed to markdown-it * @returns {string} The rendered HTML */ const mdParse = (node, content, env) => { _mdCurrentNode = node const result = _mdParseRaw(content, env) _mdCurrentNode = null return result } /** Object tree of folders and files (typedef) * @typedef {object} FolderTree * @property {string} name - Name of the folder or file * @property {string} type - 'folder' or 'file' * @property {string} path - Path of the folder or file in URL form for matching to details if required * @property {FolderTree[]} [children] - Children if folder */ // ! TODO Move to fs lib /** walkFolderStructure - Recursively walks a folder structure and returns a nested object representing folders and files. * Folders are only included if they contain an index.md or _index.md file. Any folder or file starting with _ (except _index.md) or . is ignored. * @param {string} dirPath - The root directory to start walking from * @param {string} [_startPath] - The initial starting path (used internally) * @param {number} [_level] - Current recursion depth (used internally to prevent infinite loops) * @returns {Promise<FolderTree|null>} Nested object representing the folder structure * @throws {Error} If the directory cannot be read * @example * // Example usage: * const structure = walkFolderStructure('/docs'); */ const walkFolderStructure = async (dirPath, _startPath, _level = 0) => { if (_level >= 10) { throw new Error(`[walkFolderStructure] Maximum folder depth (10) exceeded at: ${dirPath}`) } if (_level === 0) _startPath = dirPath /** Helper to check if a name should be ignored * @param {string} name - The name of the file or folder * @returns {boolean} True if the name should be ignored */ const shouldIgnore = name => (name.startsWith('_') && name !== '_index.md') || name.startsWith('.') /** Read directory and filter out ignored entries */ let entries try { entries = readdirSync(dirPath, { withFileTypes: true, }) } catch (err) { throw new Error(`Unable to read directory: ${dirPath}`) } // Ignore entries starting with _ or . entries = entries.filter(e => !shouldIgnore(e.name)) // Only include this folder if it contains index.md, _index.md, or foldername.md const dirBasename = basename(dirPath) const hasIndexMd = entries.some(e => e.isFile() && ( e.name === 'index.md' || e.name === '_index.md' || e.name === `${dirBasename}.md` )) if (!hasIndexMd) return null /** @type {FolderTree} */ const node = { name: dirBasename, type: 'folder', path: urlJoin('/', relative(_startPath, dirPath)), children: [], } for (const entry of entries) { const fullPath = join(dirPath, entry.name) if (entry.isDirectory()) { const child = await walkFolderStructure(fullPath, _startPath, _level + 1) if (child) node.children.push(child) } else if (entry.isFile() && entry.name !== 'index.md' && entry.name !== '_index.md' && entry.name !== `${dirBasename}.md`) { node.children.push({ name: entry.name, type: 'file', path: urlJoin('/', relative(_startPath, fullPath)), }) } } return node } /** Main (module) variables - acts as a configuration object * that can easily be passed around. */ const mod = { /** @type {Function|undefined} Reference to a promisified version of RED.util.evaluateNodeProperty*/ // evaluateNodeProperty: undefined, /** @type {string} Custom Node Name - has to match with html file and package.json `red` section */ nodeName: 'markweb', /** @type {string} Path to the default configuration folder */ defaultConfigPath: join(__dirname, '..', '..', 'templates', '.markweb-defaults'), } // #endregion ----- Module level variables ---- // /** 1) Complete module definition for our Node. This is where things actually start. * As a module-level named function, it will inherit `mod` and other module-level variables * @param {runtimeRED} RED The Node-RED runtime object */ function ModuleDefinition(RED) { // NB: A reference to the RED object (uib.RED) is defined in the runtime plugin and passed to the uibGlobalConfig module, so it can be accessed from any module that requires it. /** Register a new instance of the specified node type (2) */ RED.nodes.registerType(mod.nodeName, nodeInstance, { // settings: { // uibMarkwebCheckLibs: { value: uib.markedLibs, exportable: true, }, // }, }) } /** 2) This is run when an actual instance of our node is committed to a flow * @param {runtimeNodeConfig & uibMwNode} config The Node-RED node instance config object * @this {runtimeNode & uibMwNode} */ function nodeInstance(config) { // As a module-level named function, it will inherit `mod` and other module-level variables // If you need it - which you will here - or just use uibGlobalConfig.RED if you prefer: const RED = uib.RED if (RED === null) return const log = RED.log // Create the node instance - `this` can only be referenced AFTER here RED.nodes.createNode(this, config) this.statusDisplay = { fill: 'blue', shape: 'dot', text: 'Configuring node', } setNodeStatus( this ) /** Transfer config items from the Editor panel to the runtime */ this.source = config.source ?? '' this.url = config.url ?? 'markweb' this.name = config.name ?? '' this.configFolder = config.configFolder ?? '' this.sourceFolder = '' // Used in web.instanceSetup(), should be '' // TODO: Add option to set title and description in the editor, which can then be used in the FE for display and SEO purposes. For now, just use the url as the title. this.title = config.title ?? '' this.descr = config.descr ?? '' // Make sure the url is valid & prefix with nodeRoot if needed this.url = urlJoin(uib.nodeRoot, this.url.trim()) // source folder cannot be undefined/null/blank this.source = this.source.trim() if (!this.source) { this.error('πŸŒπŸ•ΈοΈπŸ›‘[uibuilder:markweb] Source folder cannot be blank. Please set a valid source folder in the node configuration.') return } // Handle special demo source value - points to the included demo folder this.isDemo = false if (this.source === '[DEMO]') { this.isDemo = true this.source = join(__dirname, 'templates', '..', '..', '..', 'templates', 'markweb-demo') } else if (this.source === '[DOCS]') { // this.isDemo = true this.source = join(__dirname, '..', '..', 'docs') } // if source folder is a relative path, make it relative to userDir if (isAbsolute(this.source)) { this.instanceFolder = this.source } else { this.instanceFolder = join(RED.settings.userDir, this.source) } // Check if instanceFolder exists and is readable? Error if not try { accessSync( this.instanceFolder, 'r' ) } catch (err) { this.error(`πŸŒπŸ•ΈοΈπŸ›‘[uibuilder:markweb] Source folder must be readable. Please check permissions. Source="${this.instanceFolder}"`) return } // Keep a log of the active instances uib.mwinstances[this.id] = this.url log.trace(`πŸŒπŸ•ΈοΈ[markweb:nodeInstance:${this.url}] Node uib.mwinstances registered: ${JSON.stringify(uib.mwinstances)}`) uib.apps[this.url] = { node: this.id, url: this.url, title: this.title, descr: this.descr, type: 'markweb', } // if config folder is a relative path, make it relative to userDir this.configFolder = this.configFolder.trim() // Check if configFolder exists and is readable - set watcher. Config will use default fldr if not processConfig(this) // Holder for page index - Map this.index = new Map() // Build page index asynchronously buildIndexes(this) .then(() => { return true }) .catch((err) => { log.error(`πŸŒπŸ•ΈοΈπŸ›‘[markweb:nodeInstance:${this.url}] Uncaught error in buildIndexes: ${err.message}`) }) // File/folder change watch (sets this.watcher so it can be cancelled), also notifies connected clients setupFileWatcher(this) // Set up web services for this instance (static folders, middleware, etc) log.info(`πŸŒπŸ•ΈοΈ[markweb] New URL "${this.url}", source fldr "${this.instanceFolder}", config fldr "${this.configFolder || '[none]'}"`) web.instanceSetup(this, `${this.url}/:morePath(*)?`, handler.bind(this), { searchHandler: searchHandler.bind(this), }) // Socket.IO instance configuration. Each deployed instance has it's own namespace sockets.addNS(this) // NB: Namespace is set from url // Save a reference to sendToFe to allow this and other nodes referencing this to send direct to clients this.sendToFe = sockets.sendToFe.bind(sockets) // Define internal control messages and processing this.internalControls = internalControlMsgHooks(this, log) // Extend Markdown-IT with %%...%% directive & {{...}} variable processing mdExtension(this) // Clean up watchers on node close this.on('close', async () => { if (this.watcher) { await this.watcher.close() log.trace(`πŸŒπŸ•ΈοΈ[markweb:close:${this.url}] File watcher closed`) } if (this.configWatcher) { await this.configWatcher.close() log.trace(`πŸŒπŸ•ΈοΈ[markweb:close:${this.url}] Config watcher closed`) } }) log.trace(`πŸŒπŸ•ΈοΈ[markweb:nodeInstance:${this.url}] URL . . . . . : ${urlJoin( uib.nodeRoot, this.url )}`) log.trace(`πŸŒπŸ•ΈοΈ[markweb:nodeInstance:${this.url}] Source files . : ${this.instanceFolder}`) // We only do the following if io is not already assigned (e.g. after a redeploy) this.statusDisplay.text = 'Node Initialised' setNodeStatus( this ) /** Handle incoming msg's - note that the handler fn inherits `this` */ this.on('input', inputMsgHandler) // Allow editor to query whether marked and other dependencies are available // RED.httpAdmin.get(`/uibuilder/chk-markweb`, (req, res) => { // res.status(200).json( chkLibs ) // }) } // ---- End of nodeInstance ---- // /** 3) Run whenever a node instance receives a new input msg * NOTE: `this` context is still the parent (nodeInstance). * See https://nodered.org/blog/2019/09/20/node-done * @param {object} msg The msg object received. * @param {Function} send Per msg send function, node-red v1+ * @param {Function} done Per msg finish function, node-red v1+ * @this {runtimeNode & uibMwNode} */ function inputMsgHandler(msg, send, done) { // const RED = uib.RED // If the input msg is a uibuilder control msg, then drop it to prevent loops if ( Object.prototype.hasOwnProperty.call(msg, 'uibuilderCtrl') ) { done() return } // @ts-ignore pass the complete msg object to the uibuilder client sockets.sendToFe( msg, this, uib.ioChannels.server ) // We are done done() } // #region ----- Module-level support functions ----- // /** Define internal control message hooks for this node instance * @param {runtimeNode & uibMwNode} node The current node instance * @param {object} log The RED.log object for logging * @returns {object} An object containing internal control message handler functions * @this {runtimeNode & uibMwNode} */ function internalControlMsgHooks(node, log) { return { /** Internal control hook to perform a search, returns a list of results to the requesting client * @param {object} msg A control message with at least { query: string } */ 'search': (msg) => { const returnTopic = '_search-results' const index = node.index if (!index) { log.warn(`πŸŒπŸ•ΈοΈβš οΈ[markweb:nodeInstance:search}] Search attempted but index not ready for instance URL: "${node.url}"`) node.sendToFe({ topic: returnTopic, query: msg.query, results: [], error: 'Search index not ready', _socketId: msg._socketId, // Ensure response goes to requesting client only }, node, uib.ioChannels.control) return } let results try { results = doSearch(index, msg.query) } catch (e) { log.error(`πŸŒπŸ•ΈοΈπŸ›‘[markweb:nodeInstance:search}] Error performing search for instance URL "${node.url}": ${e.message}`) } node.sendToFe({ topic: returnTopic, query: msg.query, results: results, _socketId: msg._socketId, }, node, uib.ioChannels.control) }, /** Internal control hook to navigate to a different page * @param {object} msg A control message with at least { toUrl: string } */ 'navigate': doNavigate.bind(node), /** Internal control hook to return just the sidebar navigation HTML * @param {object} msg A control message with at least { currentPath: string } */ 'get-sidebar-nav': (msg) => { const returnTopic = '_sidebar-nav-result' const currentPath = msg.currentPath || '/' // Check for sidebar.json override in config folder (don't warn if not found) const sidebarOverride = readConfigFile(node, 'sidebar.json', true) let navIndexHtml if (sidebarOverride && Array.isArray(sidebarOverride)) { navIndexHtml = buildSidebarFromJson(sidebarOverride, currentPath) } else { const indexOptions = { start: 0, end: 3, type: 'both', sidebar: true, id: 'sidebar-nav', title: 'Sidebar navigation index', } const tree = createTree(false, { path: currentPath, }, indexOptions, node) navIndexHtml = renderSidebarTree(tree, { currentPath, }) } node.sendToFe({ topic: returnTopic, navHtml: navIndexHtml, _socketId: msg._socketId, }, node, uib.ioChannels.control) }, } } // #region -- %%...%% specials processing (see processTemplates for calls) -- // /** Render a date placeholder with optional formatting and frontmatter date types * @param {'date'} key The special key being processed * @param {object} attributes The current pages attributes * @param {runtimeNode & uibMwNode} node The current node instance * @param {object} [options] Optional options passed to the %%date [options]%% instruction * @param {string} [options.type] Type of date to show: 'now' (default), 'created', 'updated', or any frontmatter date field * @param {string} [options.format] Date format string (default: 'YYYY-MM-DD'). Uses standard tokens. Underscore translates to space * @returns {string} Formatted date string */ function renderDate(key, attributes, node, options) { if (!options) options = {} const type = options.type || 'now' const format = options.format || 'YYYY-MM-DD' let date if (type === 'now') { // Use current date/time date = new Date() } else { // Try to get date from frontmatter attributes const dateValue = attributes[type] if (!dateValue) { // Date field not found in frontmatter return `[${type} date not found]` } date = new Date(dateValue) if (isNaN(date.getTime())) { // Invalid date value return `[Invalid ${type} date: ${dateValue}]` } } return formatDateIntl(date, format) } /** Wrap main content in a div#content & convert markdown to html * @param {'content'} key The special key being processed * @param {object} attributes The current pages attributes * @param {runtimeNode & uibMwNode} node The current node instance * @param {object} [options] Optional options passed to the %%nav [options]%% instruction. Applied as html attributes to the wrapper div * @returns {string} Wrapped content as an html string */ // function bodyWrapper(key, attributes, node, options) { // console.log('πŸŒπŸ•ΈοΈ[bodyWrapper] options=', options) // let attrs = '' // if (options) { // attrs = Object.entries(options) // .map(([k, v]) => `${k}="${v}"`) // .join(' ') // } // let html = '' // try { // html = mdParse(attributes.content, attributes) // } catch (e) { /* ignore errors */ } // return `<div id="content" ${attrs} data-directive="body">${html || ''}</div>` // // return `<div id="content" ${attrs} data-directive="body">{{body}}</div>` // } /** Create navigation menu HTML and return it * @param {'nav'} key The special key being processed * @param {object} attributes The current pages attributes * @param {runtimeNode & uibMwNode} node The current node instance * @param {object} options Optional options passed to the %%nav [options]%% instruction * @returns {string} The generated navigation HTML */ function createNav(key, attributes, node, options) { // console.log(' >>πŸŒπŸ•ΈοΈ[createNav] ', key, options, attributes) if (!options) options = {} options.nav = true let currentStart = false if ('start' in options) options.start = Number(options.start) else { // No start given so assume current level options.start = attributes.depth currentStart = true } if ('depth' in options) options.end = Number(options.depth) if ('end' in options) options.end = Number(options.end) else if (options.depth) options.end = options.start + options.depth else if (currentStart) options.end = attributes.depth else options.end = 3 // Max depth default if (!('type' in options)) { // type can be 'files', 'folders', or 'both' options.type = 'folders' } options = { start: 0, end: 3, type: 'folders', orient: 'horizontal', nav: true, } const links = indexListing( key, attributes, node, options ) const isHorizontal = options?.orient === 'horizontal' || !options?.orient const searchBox = /* html */` <search> <form id="search-form" role="search" onsubmit="return false"> <input type="search" id="search-input" placeholder="Search..." aria-label="Search pages"> </form> </search> ` return ` <nav data-directive="nav" data-replace aria-label="Main menu" class="${options?.orient || 'horizontal'}" role="navigation"> ${links} ${searchBox} </nav> ` } /** Parse a duration string into milliseconds * Duration format: number + type, e.g. '1w' (week), '1d' (day), '1m' (month), '1y' (year), '1h' (hour) * Number can be negative, e.g. '-1w' for 1 week offset * @param {string} durationStr - Duration string like '1w', '-2d', '3m' * @returns {number|null} Duration in milliseconds or null if invalid */ function parseDuration(durationStr) { if (!durationStr || typeof durationStr !== 'string') return null const match = durationStr.trim().match(/^(-?\d+(?:\.\d+)?)\s*([a-zA-Z]+)$/) // eslint-disable-line security/detect-unsafe-regex if (!match) return null const value = parseFloat(match[1]) const unit = match[2].toLowerCase() // Conversion factors to milliseconds const msPerSecond = 1000 const msPerMinute = msPerSecond * 60 const msPerHour = msPerMinute * 60 const msPerDay = msPerHour * 24 const msPerWeek = msPerDay * 7 const msPerMonth = msPerDay * 30 // Approximate const msPerYear = msPerDay * 365 // Approximate const units = { s: msPerSecond, sec: msPerSecond, second: msPerSecond, seconds: msPerSecond, min: msPerMinute, minute: msPerMinute, minutes: msPerMinute, h: msPerHour, hr: msPerHour, hour: msPerHour, hours: msPerHour, d: msPerDay, day: msPerDay, days: msPerDay, w: msPerWeek, wk: msPerWeek, week: msPerWeek, weeks: msPerWeek, m: msPerMonth, mo: msPerMonth, month: msPerMonth, months: msPerMonth, y: msPerYear, yr: msPerYear, year: msPerYear, years: msPerYear, } if (!(unit in units)) return null return value * units[unit] } /** Create an HTML list of links to pages the current level or between the given levels * If start/end not provided, assume only the current level * @param {'index'} key The special key being processed * @param {object} attributes The current pages attributes * @param {runtimeNode & uibMwNode} node The current node instance * @param {object} [options] Optional options passed to the %%nav [options]%% instruction * @param {string|number} [options.start] The starting depth level (0 = root) * @param {string|number} [options.end] The ending depth level (0 = root) * @param {string} [options.from] Start date/time for filtering (any JS Date() format) * @param {string} [options.to] End date/time for filtering (any JS Date() format, or 'now') * @param {string} [options.duration] Duration offset from from/to (e.g. '1w', '-2d', '3m') * @param {string|number} [options.latest] Return only the N most recent pages (sorted by updated/created date) * @returns {string} The generated navigation HTML */ function indexListing(key, attributes, node, options) { // Max depth default const maxDepth = 5 let currentStart = false if (!options) options = {} if (!('nav' in options)) options.nav = false // Check if latest is specified - it changes default behavior for path/depth const hasLatest = 'latest' in options const hasExplicitStart = 'start' in options const hasExplicitEnd = 'end' in options || 'depth' in options const hasExplicitDateRange = 'from' in options || 'to' in options || 'duration' in options if (hasExplicitStart) { options.start = Number(options.start) } else { // No start given so assume current level options.start = Number(attributes.depth) currentStart = true } // if ('depth' in options) options.end = Number(options.depth) if ('end' in options) { options.end = Number(options.end) } else if ('depth' in options) { options.end = Number(options.start) + Number(options.depth) } else if (hasLatest) { // When latest is specified without explicit end or depth, use current page depth + max depth options.end = Number(attributes.depth) + maxDepth } else if (currentStart) { // When currentStart is specified without explicit end or depth, use start + max depth options.end = Number(attributes.depth) + maxDepth // options.end = Number(attributes.depth) } else { options.end = maxDepth // Max depth default } if (!('type' in options)) { // type can be 'files', 'folders', or 'both' options.type = 'both' } // Process date/time range filtering options (only if explicitly provided) // `from` and `to` accept date/time strings in any JS Date() recognized format // `to` can also be 'now' representing current date/time // `duration` can be used with either `from` or `to` (not both) as an offset // Duration format: number + type, e.g. '1w' (week), '1d' (day), '1m' (month), '1y' (year), '1h' (hour) // Number can be negative, e.g. '-1w' for 1 week ago if (hasExplicitDateRange) { const now = new Date() let fromDate = null let toDate = null // Parse 'to' first (may be 'now') if ('to' in options) { if (options.to.toLowerCase() === 'now') { toDate = now } else { toDate = new Date(options.to) if (isNaN(toDate.getTime())) toDate = null } } // Parse 'from' if ('from' in options) { fromDate = new Date(options.from) if (isNaN(fromDate.getTime())) fromDate = null } // Handle duration offset (only if one of from/to is missing) if ('duration' in options && !(fromDate && toDate)) { const offset = parseDuration(options.duration) if (offset !== null) { if (fromDate && !toDate) { // from + duration = to toDate = new Date(fromDate.getTime() + offset) } else if (toDate && !fromDate) { // to - duration = from (note: offset is added, so negative duration goes back) fromDate = new Date(toDate.getTime() - offset) } else if (!fromDate && !toDate) { // No from or to, use now as base with duration // Assume duration is relative to now, creating a range ending at now toDate = now fromDate = new Date(now.getTime() - offset) } } } // Store parsed dates in options for filteredIndex if (fromDate) options.fromDate = fromDate if (toDate) options.toDate = toDate } // Parse latest option - returns only the N most recent pages if ('latest' in options) { options.latest = Number(options.latest) if (isNaN(options.latest) || options.latest < 1) { delete options.latest // Invalid value, ignore } } const tree = createTree(currentStart, attributes, options, node) // If start and end levels are the same, omit folder (index) entries const sameLevel = options.start === options.end return renderTree(tree, options, sameLevel, options.nav, 0, attributes.path) } /** Create search results wrapper HTML * @param {'searchResults'} key The special key being processed * @param {object} attributes The current pages attributes * @param {runtimeNode & uibMwNode} node The current node instance * @param {object} [options] Optional options passed to the %%search-results [options]%% instruction. * @returns {string} The generated search results HTML */ function searchResultsWrapper(key, attributes, node, options) { let headTxt if (options && options.head === 'short') { headTxt = '<span id="search-count">N/A</span> results' } else { headTxt = 'Search results for "<span id="search-query">N/A</span>" (<span id="search-count">N/A</span>)' } // console.log('πŸŒπŸ•ΈοΈ[searchResultsWrapper] options=', options) return /* html */` <article id="search-results" hidden> <div id="search-header"> <span>${headTxt}</span> <button class="search-close" aria-label="Close search results">Γ—</button> </div> <div id="search-details"></div> </article> ` } /** Render a sidebar navigation tree with collapsible details/summary elements * @param {Map} map The tree map to render * @param {object} options Sidebar rendering options * @param {string} options.currentPath The current page path for highlighting * @param {number} [_level] Current recursion level (internal use) * @returns {string} Nested HTML with details/summary for collapsible sections */ function renderSidebarTree(map, options, _level = 0) { if (map.size === 0) return '' const priorityRank = (priority) => { if (priority === 'high') return 0 if (priority === 'low') return 2 return 1 } const sortedEntries = [...map.values()].sort((a, b) => { const rankA = priorityRank(a.sortPriority) const rankB = priorityRank(b.sortPriority) if (rankA !== rankB) return rankA - rankB const titleA = a.title || a.path || '' const titleB = b.title || b.path || '' return titleA.localeCompare(titleB) }) let html = '<ul>' for (const entry of sortedEntries) { // Skip entries with no valid path to avoid generating href="undefined" in HTML if (!entry.path) continue // Skip folder entries that have no index.md - clicking them would result in a 404 if (entry.path.endsWith('/') && !entry.hasIndex) continue const hasChildren = entry.children && entry.children.size > 0 const isCurrentPage = options.currentPath === entry.path || options.currentPath === entry.path.replace(/\/$/, '') // const activeClass = isCurrentPage ? ' class="sidebar-active"' : '' const activeClass = isCurrentPage ? ' class="active-link"' : '' const titleAttr = entry.description ? ` title="${entry.description.replace(/"/g, '&quot;')}"` : '' if (hasChildren) { // Use details/summary for collapsible sections const childHtml = renderSidebarTree(entry.children, options, _level + 1) html += `<li> <details data-path="${entry.path}"> <summary${activeClass}${titleAttr}><a href="${entry.path}">${entry.title}</a></summary> ${childHtml} </details> </li>` } else { html += `<li${activeClass}><a href="${entry.path}"${titleAttr}>${entry.title}</a></li>` } } html += '</ul>' return html } /** Create a hierarchical tree structure from the filtered index for sidebar or nav rendering * @param {boolean} currentStart Whether to use the current page's depth as the start level (true) or use options.start (false) * @param {object} attributes The current pages attributes * @param {object} indexOptions Options for filtering the index * @param {runtimeNode & uibMwNode} node The current node instance * @returns {Map<string, {path: string, title: string, description: string, children: Map<string, any>}>} The hierarchical tree structure */ function createTree(currentStart, attributes, indexOptions, node) { const filtered = filteredIndex(currentStart, attributes, indexOptions, node) // if (!indexOptions.sidebar) console.log(` >>πŸŒπŸ•ΈοΈ[markweb:createTree] Filtered index for ${attributes.toUrl}`, { currentStart, indexOptions, filtered, }) // Build hierarchical tree from filtered index (similar to indexListing) const tree = new Map() for (const [path, doc] of filtered) { // Determine title - prefer shortTitle over title let title = doc.shortTitle || doc.title if (doc.path === '/') title = 'Home' else if (doc.type === 'folder' && (!title || title === 'index')) { const segments = doc.path.replace(/\/$/, '').split('/') .filter(Boolean) const rawTitle = segments[segments.length - 1] || doc.path.slice(1, -1) title = rawTitle.replace(/[_]/g, ' ').replace(/\b\w/g, c => c.toUpperCase()) } // Split path into segments const segments = path .replace(/\/$/, '') .split('/') .filter(Boolean) if (segments.length === 0) { if (!tree.has('/')) { tree.set('/', { path: '/', title, description: doc.description || '', sortPriority: doc.sortPriority || '', hasIndex: true, children: new Map(), }) } else { const rootEntry = tree.get('/') rootEntry.title = title rootEntry.description = doc.description || '' rootEntry.sortPriority = doc.sortPriority || '' } continue } // Navigate/create tree structure let current = tree for (let i = 0; i < segments.length; i++) { const segment = segments[i] const isLast = i === segments.length - 1 if (!current.has(segment)) { const folderPath = '/' + segments.slice(0, i + 1).join('/') + '/' current.set(segment, { path: isLast ? path : folderPath, title: isLast ? title : segment, description: isLast ? (doc.description || '') : '', sortPriority: isLast ? (doc.sortPriority || '') : '', hasIndex: isLast ? true : node.index.has(folderPath), children: new Map(), }) } else if (isLast) { const nodeEntry = current.get(segment) nodeEntry.path = path nodeEntry.title = title nodeEntry.description = doc.description || '' nodeEntry.sortPriority = doc.sortPriority || '' nodeEntry.hasIndex = true } current = current.get(segment).children } } return tree } /** Create sidebar HTML with navigation index, TOC, and search * @param {'sidebar'} key The special key being processed * @param {object} attributes The current pages attributes * @param {runtimeNode & uibMwNode} node The current node instance * @param {object} [options] Optional options passed to the %%sidebar [options]%% instruction * @param {string} [options.search] Whether to include search box (default: 'true') * @param {string} [options.open] Whether sidebar starts open (default: 'true') * @param {string} [options.width] Default sidebar width (default: '20em') * @param {string} [options.start] Start depth for nav index (default: '0') * @param {string} [options.end] End depth for nav index (default: '5') * @param {string} [options.position] Sidebar position - 'left' or 'right' (default: 'left') * @returns {string} The generated sidebar HTML */ function createSidebar(key, attributes, node, options) { const log = uib.RED.log log.trace(`πŸŒπŸ•ΈοΈ[createSidebar] Creating sidebar for ${attributes.path}`, options) if (!options) options = {} // Parse options with defaults const showSearch = options.search !== 'false' // default=true const start = 'start' in options ? Number(options.start) : 0 const end = 'end' in options ? Number(options.end) : 3 // 4 levels // Check for sidebar.json override in config folder (don't warn if not found) const sidebarOverride = readConfigFile(node, 'sidebar.json', true) // If we have a sidebar override, use it instead let navIndexHtml if (sidebarOverride && Array.isArray(sidebarOverride)) { // Build tree from sidebar.json structure navIndexHtml = buildSidebarFromJson(sidebarOverride, attributes.path) } else { // Build the navigation index using filtered index const indexOptions = { start, end, type: 'both', sidebar: true, id: 'sidebar-nav', title: 'Sidebar navigation index', } const tree = createTree(false, attributes, indexOptions, node) // Render the tree with collapsible sections navIndexHtml = renderSidebarTree(tree, { currentPath: attributes.path, }) } // Generate data attributes for client-side use const dataAttrs = `data-directive="sidebar"` // Build the search box & results HTML if enabled const searchBoxHtml = showSearch ? /* html */` <search> <form id="search-form" role="search" onsubmit="return false"> <input type="search" id="search-input" placeholder="Search..." aria-label="Search pages" > </form> </search> ${searchResultsWrapper(key, attributes, node, { head: 'short', })} ` : '' // Build the complete sidebar HTML return /* html */` <div id="sidebar-resizer" title="Resize sidebar"> <label id="sidebar-toggle" class="sidebar-toggle" title="Toggle sidebar"> <input type='checkbox'> <span></span><span></span><span></span> </label> </div> <aside id="sidebar" ${dataAttrs} aria-label="Page sidebar"> <div class="sidebar-content"> ${searchBoxHtml} <div class="sidebar-tabs" role="tablist"> <button id="sidebar-tab-nav" class="sidebar-tab active" role="tab" aria-selected="true" aria-controls="sidebar-panel-nav"> Navigation </button> <button id="sidebar-tab-toc" class="sidebar-tab" role="tab" aria-selected="false" aria-controls="sidebar-panel-toc"> Contents </button> </div> <uib-var id="sidebar-panel-nav" class="sidebar-panel active" role="tabpanel" aria-labelledby="sidebar-tab-nav" variable="sidebar-nav" type="html"> ${navIndexHtml} </uib-var> <div id="sidebar-panel-toc" class="sidebar-panel" role="tabpanel" aria-labelledby="sidebar-tab-toc" hidden> <nav id="sidebar-toc" aria-label="Table of contents"> <!-- TOC generated client-side from page headings --> <uib-var variable="sidebar-toc" type="html"> <p>Table of contents will appear here based on page headings.</p> </uib-var> </nav> </div> </div> </aside> ` } /** Build sidebar navigation from a sidebar.json override file * @param {Array<object>} items Array of sidebar items from sidebar.json * @param {string} currentPath The current page path for highlighting * @returns {string} HTML for the sidebar navigation */ function buildSidebarFromJson(items, currentPath) { if (!items || !Array.isArray(items) || items.length === 0) return '' let html = '<ul>' for (const item of items) { // Skip items with no valid path to avoid generating href="undefined" in HTML if (!item.path) continue const hasChildren = item.children && Array.isArray(item.children) && item.children.length > 0 const isCurrentPage = currentPath === item.path const activeClass = isCurrentPage ? ' class="sidebar-active"' : '' const titleAttr = item.description ? ` title="${item.description.replace(/"/g, '&quot;')}"` : '' const title = item.shortTitle || item.title || item.path if (hasChildren) { const childHtml = buildSidebarFromJson(item.children, currentPath) html += `<li> <details data-path="${item.path}"> <summary${activeClass}${titleAttr}><a href="${item.path}">${title}</a></summary> ${childHtml} </details> </li>` } else { html += `<li${activeClass}><a href="${item.path}"${titleAttr}>${title}</a></li>` } } html += '</ul>' return html } /** Render copyright information from template * @param {'copyright'} key The special key being processed * @param {object} attributes The current pages attributes * @param {runtimeNode & uibMwNode} node The current node instance * @param {object} [options] Optional options (not used for copyright) * @returns {string} Formatted copyright HTML */ function renderCopyright(key, attributes, node, options) { // Read the copyright template file (with fallback to default) const template = readConfigFile(node, 'copyright-template.html', true) if (!template) { // If no template found, return a basic fallback return 'Copyright Β© All rights reserved.' } // Process the template to replace any nested directives and variables // This allows the template to use %%date%% and {{author}} etc. return processTemplates(template, node, attributes, 'renderCopyright') } /** Add more markdown-it extensions along with their handlers * Has to be done here so that the handlers can pick up page attributes and node content. * "Standard" markdown-it extensions (e.g., footnotes, task lists) are added in uib-md-utils * as they don't need access to page attributes. * NOTE: Handlers receive: * args: `[argname=value, ...]` arguments object * env: The page attributes, AKA the page's frontmatter variables * @param {runtimeNode & uibMwNode} node The current node instance */ function mdExtension(node) { // Only register plugins once on the shared md instance if (md._markwebPluginsRegistered) return md._markwebPluginsRegistered = true /** Handler functions for each specific directive. * Uses _mdCurrentNode (set by the mdParse wrapper) for node-specific data. */ const directiveHandlers = { // Return an index list of pages from the current page level index: (args, env) => { const il = indexListing('index', env, _mdCurrentNode, args) // console.log('πŸŒπŸ•ΈοΈ[mdExtension:index] ', {il, args, env}) return il }, } /** Convert {{varname}} to HTML spans with the variable value from frontmatter attributes * This allows using frontmatter variables anywhere in markdown content AND in Templates * Supported arguments (inside [...]): before, after, prefix (alias for before), default * @param {object} args The [args] object passed from the markdown content * @param {object} env The page attributes, AKA the page's frontmatter variables * @returns {string} The rendered HTML for the fm variable */ const fmVariablesHandler = (args, env) => { const varName = args.varName let value = env[varName] let errClass = '' if (value === undefined) { if (args.default !== undefined) { value = args.default } else { value = `[Unknown variable: "${varName}"]` errClass = ' variable-unknown' } } // Only render before/after when we have a real value (not an error placeholder) const hasValue = errClass === '' const before = hasValue ? (args.before ?? args.prefix ?? '') : '' const after = hasValue ? (args.after ?? '') : '' // Set data-before/data-after attributes on the element for client-side use (same as uib-var) const escAttr = (s) => { return s.replace(/&/g, '&amp;') .replace(/"/g, '&quot;') .replace(/</g, '&lt;') .replace(/>/g, '&gt;') } const dataBefore = before ? ` data-before="${escAttr(before)}"` : '' const dataAfter = after ? ` data-after="${escAttr(after)}"` : '' // Wrap in dummy element with data attribute for client-side processing return /* html */`<fm-var class="fm-${varName}${errClass}" data-fmvar="${varName}"${dataBefore}${dataAfter}>${before}${value}${after}</fm-var>` } // Extend markdown-it with these plugins md .use(directivePlugin, directiveHandlers) .use(fmVariablesPlugin, fmVariablesHandler) } // #endregion -- %%...%% specials processing -- // /** Build search & nav indexes from all markdown files asynchronously & notifies connected clients * @param {runtimeNode & uibMwNode} node Instance `this` context * @returns {Promise<void>} */ async function buildIndexes(node) { const log = uib.RED.log const strt = performance.now() const url = node.url const instanceFolder = node.instanceFolder const index = node.index const indexFolder = instanceFolder.replace(/\\/g, '/') node.isIndexing = true // TODO: Use async fg const prefix = `${indexFolder}/**` let files try { // Get all regular .md files, excluding _ and . prefixed files/folders const regularFiles = fgSync( `${prefix}/*.md`, { ignore: [`${prefix}/_*/**`, `${prefix}/.*`, `${prefix}**/_*`, `${prefix}**/.*/**`, `${prefix}**/.*`], } ) // Also include _index.md files specifically (Hugo/Obsidian compatibility), but not inside _* or .* folders const indexFiles = fgSync( `${prefix}/_index.md`, { ignore: [`${prefix}/_*/**`, `${prefix}/.*`, `${prefix}**/_*`, `${prefix}**/.*/**`, `${prefix}**/.*`], } ) // Merge and deduplicate files = [...new Set([...regularFiles, ...indexFiles])] } catch (e) { log.error(`πŸŒπŸ•ΈοΈπŸ›‘[markweb:buildIndex:${url}] Error reading markdown files from source folder "${indexFolder}": ${e.message}`) files = [] } node.isIndexing = false // Process all files in parallel - updates index if needed // ! WARNING: While this is fast, it adds index entries in a semi-random order ! const results = await Promise.allSettled( files.map(file => getMarkdownFile(node, file)) ) // Collect valid paths from results and log any errors const validPaths = new Set() results.forEach((result, i) => { if (result.status === 'fulfilled' && result.value?.path) { validPaths.add(result.value.path) } else if (result.status === 'rejected') { log.error(`πŸŒπŸ•ΈοΈπŸ›‘[markweb:buildIndex:${url}] Skipping file "${files[i]}" due to error: ${result.reason.message}`) } }) // Remove stale index entries for files that no longer exist for (const key of index.keys()) { if (!validPaths.has(key)) { log.trace(`πŸŒπŸ•ΈοΈ[markweb:buildIndex:${url}] Removing stale index entry: "${key}"`) index.delete(key) } } // notify ALL connected clients that indexes have changed node.sendToFe({ topic: '_indexes-changed', }, node, uib.ioChannels.control) // ! TEMPORARY - for debugging convenience // globalThis._uibuilder_.markweb.indexes[url] = node.index log.info(`πŸŒπŸ•ΈοΈ[markweb:buildIndex:${url}] Indexed "${instanceFolder}" in ${Math.round(performance.now() - strt)}ms. ${files.length} files, ${(serialize(node.index).byteLength / 1024).toFixed(0)}kb`) } /** Build a list of navigable pages from all markdown files in the source folder * @param {string} sourcePath Source folder path * @returns {Array<{path: string, title: string, file: string}>} Array of navigation items */ // function buildNavigationIndex(sourcePath) { // try { // const files = fgSync(`${sourcePath.replace(/\\/g, '/')}/**/*.md`) // return files.map((file) => { // const relativePath = relative(sourcePath, file).replace(/\\/g, '/') // const urlPath = relativePath.replace(/\.md$/, '') // const title = basename(file, '.md') // .replace(/-/g, ' ') // .replace(/\b\w/g, c => c.toUpperCase()