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
JavaScript
/* 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, '"')}"` : ''
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, '"')}"` : ''
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, '&')
.replace(/"/g, '"')
.replace(/</g, '<')
.replace(/>/g, '>')
}
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()