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,169 lines (1,044 loc) • 48.8 kB
JavaScript
/* eslint-disable jsdoc/valid-types */
/* eslint-disable @stylistic/arrow-parens */
/* eslint-disable @stylistic/key-spacing */
/* eslint-disable @stylistic/no-multi-spaces */
/** @file bin/build.mjs
* @description Single ESM build and watch script for node-red-contrib-uibuilder.
* Builds all front-end and Node.js libraries using esbuild and LightningCSS.
* Replaces the legacy gulpfile.js for all source compilation tasks.
*
* @example
* node bin/build.mjs Build everything once
* node bin/build.mjs --watch Build everything then watch for changes
* node bin/build.mjs fe Build front-end modules only
* node bin/build.mjs node Build Node.js packages only
* node bin/build.mjs css Build CSS only
* node bin/build.mjs docs Build docs bundle only
* node bin/build.mjs versions Update all version strings only (no build)
* node bin/build.mjs tag Create and push a GitHub release tag
* node bin/build.mjs fe css Build front-end and CSS
* node bin/build.mjs fe --watch Build front-end and watch for changes
*
* All configuration is centralised at the top of this file — see the "Central Configuration"
* region. To add a new library or output format, add an entry to the relevant config array.
*/
import * as esbuild from 'esbuild' // eslint-disable-line n/no-unpublished-import
// import browserslistToEsbuild from 'browserslist-to-esbuild'
import { browserslistToTargets, transform } from 'lightningcss' // eslint-disable-line n/no-unpublished-import
import browserslist from 'browserslist' // eslint-disable-line n/no-unpublished-import
import { readFile, writeFile, stat } from 'node:fs/promises'
import { readFileSync, existsSync } from 'node:fs'
import { resolve, dirname, join, basename } from 'node:path'
import { fileURLToPath } from 'node:url'
import { spawn, execFile } from 'node:child_process'
import { promisify } from 'node:util'
const execFileAsync = promisify(execFile)
// #region ---- Bootstrap ----
const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)
/** @type {string} Absolute path to the project root directory */
const ROOT = resolve(__dirname, '..')
/** @type {string} Current package version — read from package.json at startup and refreshed by the
* package.json watcher in watch mode whenever the version field changes.
*/
let PKG_VERSION = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).version
// #endregion ---- Bootstrap ----
// #region ---- Central Configuration ----
// ─────────────────────────────────────────────────────────────────────────────
// All build configuration is defined here. To add a new output or change paths,
// edit the relevant constant below — no other code changes should be required.
// ─────────────────────────────────────────────────────────────────────────────
// --- Build targets ─────────────────────────────────────────────────────────
/** Explicit esbuild browser targets — early 2019 baseline.
* Hardcoded rather than derived from browserslist so the JS build target is
* fully deterministic and immune to browserslist database updates.
* Minimum versions per caniuse for full ES2018 destructuring support:
* chrome 60, firefox 55, opera 47, safari 11.1, ios_saf 11.4, edge 79
* Targeting early 2019: chrome73, firefox66, opera60, safari12.1, ios12.2, edge79
* @type {string[]}
*/
const ESBUILD_BROWSER_TARGETS_HARDCODED = [
'chrome73',
'firefox66',
'opera60',
'safari12.1',
'ios12.2',
'edge79',
]
/** @type {string} Browserslist query that controls browser support for both JS and CSS builds */
// const BROWSER_QUERY = '>=0.12%, not ie > 0'
// const BROWSER_QUERY = [
// '>=0.12%',
// 'not ie > 0',
// ... ESBUILD_BROWSER_TARGETS_HARDCODED,
// ].join(', ')
const CSS_QUERY = '>=0.12%, not ie > 0, not ios_saf < 12.2, not safari < 12.1, not edge < 79'
/** esbuild Node.js target version.
* Aligned with the Node-RED v3+ minimum requirement of Node.js 18.
* Increase to 'node22' when Node-RED v4 becomes the baseline.
* @type {string}
*/
const NODE_TARGET = 'node18'
// ─── Directory paths ───────────────────────────────────────────────────────
/** @type {string} Source directory for all front-end module source files */
const FE_SRC = 'src/front-end-module'
/** @type {string} Output directory for built front-end files */
const FE_OUT = 'front-end'
/** @type {string} Source directory for built-in web components (bundled into FE module) */
const COMPONENTS_SRC = 'src/components'
// ─── Computed browser targets ─────────────────────────────────────────────
/** Resolved browserslist result; used to derive both esbuild and LightningCSS targets */
// const _browserslistResult = browserslist(BROWSER_QUERY)
/** LightningCSS browser targets derived from the browserslist query.
* Used by the CSS build to emit forwards-compatible CSS.
*/
const LIGHTNING_TARGETS = browserslistToTargets(browserslist(CSS_QUERY))
/** Convert a browserslist result array to esbuild-compatible target strings.
* Maps browser identifiers to the esbuild format (e.g. 'chrome 120' → 'chrome120').
* Browsers not recognised by esbuild (op_mini, kaios, samsung, etc.) are filtered out.
* @param {string[]} list - Raw browserslist result array
* @returns {string[]} Deduplicated esbuild target strings
*/
// function browserslistToEsbuildTargets(list) {
// /** Maps browserslist browser names to esbuild equivalents. null = not supported by esbuild. */
// const BROWSER_MAP = {
// and_chr: 'chrome',
// and_ff: 'firefox',
// ios_saf: 'safari',
// android: 'chrome',
// op_mob: 'opera',
// op_mini: null,
// kaios: null,
// baidu: null,
// bb: null,
// and_qq: null,
// and_uc: null,
// ie: null,
// ie_mob: null,
// samsung: null,
// }
// const seen = new Set()
// /** @type {string[]} */
// const result = []
// for (const entry of list) {
// const spaceIdx = entry.lastIndexOf(' ')
// const browser = entry.slice(0, spaceIdx)
// const ver = entry.slice(spaceIdx + 1)
// if (ver === 'all') continue
// const mapped = browser in BROWSER_MAP ? BROWSER_MAP[browser] : browser
// if (!mapped) continue
// const target = `${mapped}${ver}`
// if (!seen.has(target)) {
// seen.add(target)
// result.push(target)
// }
// }
// return result
// }
/** esbuild browser targets derived from the browserslist query.
* Used for all front-end JavaScript builds.
* @type {string[]}
*/
// const ESBUILD_BROWSER_TARGETS = browserslistToEsbuildTargets(_browserslistResult)
const ESBUILD_BROWSER_TARGETS = ESBUILD_BROWSER_TARGETS_HARDCODED
// ─── Version file configuration ────────────────────────────────────────────
/** Type: VersionFileEntry
* @typedef {object} VersionFileEntry
* @property {string} file Relative path from project root to the source file
* @property {RegExp} regex Regular expression matching the version string in the file
* @property {'semantic'|'date'} type
* 'semantic' → version set to the current package.json version (e.g. '7.7.0')
* 'date' → version set to source file's last modified date (e.g. '2026-04-21')
*/
/** Source files whose embedded version strings are automatically updated before building.
*
* - type 'semantic': replaces the semver number with the current package.json version.
* The regex must match the complete version declaration including the '-src' suffix.
* - type 'date': replaces a YYYY-MM-DD date with the source file's last modified date.
* The regex may include one capture group for a leading prefix (used as-is in the replacement).
*
* The CSS entry is handled directly inside buildCSS() and is included here for documentation.
* @type {VersionFileEntry[]}
*/
const VERSION_FILES = [
{ file: `${FE_SRC}/uibuilder.module.mjs`, regex: /version = '[\d.]+-src'/, type: 'semantic', },
{ file: `${FE_SRC}/ui.mjs`, regex: /version = '[\d.]+-src'/, type: 'semantic', },
{ file: `${FE_SRC}/uibrouter.mjs`, regex: /static version = '[\d.]+-src'/, type: 'semantic', },
{ file: `${FE_OUT}/uib-brand.css`, regex: /(^ \* @version: )[\d-]+$/m, type: 'date', },
{ file: `${FE_OUT}/utils/markweb.mjs`, regex: /version = '[\d.]+-src'/, type: 'semantic', },
]
// ─── Front-end module build configurations ────────────────────────────────
/** Type: FEBuildConfig
* @typedef {object} FEBuildConfig
* @property {string} name Human-readable name used in log output
* @property {string} entryPoint Relative path from root to the source entry point
* @property {string} outBase Base path for output files (format + extension suffixes are appended)
* @property {RegExp} versionRegex Regex matching the version string in esbuild output files
* @property {string} [versionFile] Relative path from root of the source file whose embedded version is stamped before each build
* @property {string} [nodeOut] If provided, also builds a Node.js CJS bundle to this path
* @property {string[]} watchFiles Glob patterns of source files that trigger a rebuild when changed
*/
/** Front-end module build configurations.
* Each entry produces four output files:
* *.iife.min.js (IIFE, minified, with source map)
* *.iife.js (IIFE, unminified)
* *.esm.min.js (ESM, minified, with source map)
* *.esm.js (ESM, unminified)
*
* If `nodeOut` is set, a fifth output — a Node.js CJS bundle — is also produced.
* @type {FEBuildConfig[]}
*/
const FE_BUILDS = [
{
name: 'uibuilder-module',
entryPoint: `${FE_SRC}/uibuilder.module.mjs`,
outBase: `${FE_OUT}/uibuilder`,
versionRegex: /version = "([\d.]+-src)"/,
versionFile: `${FE_SRC}/uibuilder.module.mjs`,
watchFiles: [
`${FE_SRC}/uibuilder.module.mjs`,
`${FE_SRC}/ui.mjs`,
`${FE_SRC}/reactive.mjs`,
// `${FE_SRC}/tinyDom.js`,
// `${FE_SRC}/logger.js`,
`${FE_SRC}/libs/*.mjs`,
`${COMPONENTS_SRC}/ti-base-component.mjs`,
`${COMPONENTS_SRC}/uib-var.mjs`,
`${COMPONENTS_SRC}/apply-template.mjs`,
`${COMPONENTS_SRC}/uib-meta.mjs`,
`${COMPONENTS_SRC}/uib-control.mjs`,
],
},
{
name: 'ui',
entryPoint: `${FE_SRC}/ui.mjs`,
outBase: `${FE_OUT}/ui`,
versionRegex: /version = "([\d.]+-src)"/,
versionFile: `${FE_SRC}/ui.mjs`,
nodeOut: 'nodes/libs/ui.cjs',
watchFiles: [
`${FE_SRC}/ui.mjs`,
`${FE_SRC}/libs/show-overlay.mjs`,
],
},
{
name: 'uibrouter',
entryPoint: `${FE_SRC}/uibrouter.mjs`,
outBase: `${FE_OUT}/utils/uibrouter`,
versionRegex: /version = "([\d.]+-src)"/,
versionFile: `${FE_SRC}/uibrouter.mjs`,
watchFiles: [
`${FE_SRC}/uibrouter.mjs`
],
},
{
name: 'json-viewer',
entryPoint: `${COMPONENTS_SRC}/json-viewer/json-viewer.mjs`,
outBase: `${FE_OUT}/utils/json-viewer`,
nodeOut: `${FE_OUT}/utils/json-viewer.cjs`,
watchFiles: [
`${COMPONENTS_SRC}/json-viewer/json-viewer.mjs`,
`${COMPONENTS_SRC}/ti-base-component.mjs`,
],
},
]
/** Type: ExperimentalBuildConfig
* @typedef {object} ExperimentalBuildConfig
* @property {string} name Human-readable name used in log output
* @property {string} entryPoint Relative path from root to the source entry point
* @property {string} outFile Relative path from root to the single output file
* @property {string[]} watchFiles Glob patterns of source files that trigger a rebuild when changed
*/
/** Configuration for the experimental front-end module.
* Produces a single minified ESM output with a source map.
* @type {ExperimentalBuildConfig}
*/
const EXPERIMENTAL_BUILD = {
name: 'experimental',
entryPoint: `${FE_SRC}/experimental.mjs`,
outFile: `${FE_OUT}/experimental.mjs`,
watchFiles: [`${FE_SRC}/experimental.mjs`],
}
// ─── Node.js package build configurations ─────────────────────────────────
/** Type: NodeBuildConfig
* @typedef {object} NodeBuildConfig
* @property {string} name Human-readable name used in log output
* @property {string} entryPoint Relative path from root to the source entry point
* @property {string} outBase Base path for outputs (.cjs and .mjs extensions are appended)
* @property {string[]} watchFiles Glob patterns of source files that trigger a rebuild when changed
*/
/** Node.js package build configurations.
* Each entry produces two output files: CJS (.cjs) and ESM (.mjs).
* Node.js code is intentionally NOT minified to aid debugging and produce readable stack traces.
* @type {NodeBuildConfig[]}
*/
const NODE_BUILDS = [
{
name: 'uib-md-utils',
entryPoint: 'packages/uib-md-utils/src/index.mjs',
outBase: 'packages/uib-md-utils/index',
watchFiles: [
'packages/uib-md-utils/src/**/*.mjs'
],
},
{
name: 'uib-fs-utils',
entryPoint: 'packages/uib-fs-utils/src/index.mjs',
outBase: 'packages/uib-fs-utils/index',
watchFiles: [
'packages/uib-fs-utils/src/**/*.mjs'
],
},
]
/** Configuration for the mermaid browser bundle builds.
* Both IIFE and ESM outputs are produced and written to FE_OUT/utils/.
* The build is delegated to the uib-md-utils package's own build script so that
* mermaid is always resolved from the same node_modules tree as the md utilities.
* @type {{ name: string, outDir: string, watchFiles: string[] }}
*/
const MERMAID_BUILD = {
name: 'mermaid-browser-bundle',
outDir: `${FE_OUT}/utils`,
// Watch the mermaid dist file in node_modules — rebuilt when mermaid is installed/updated
watchFiles: [
'node_modules/mermaid/dist/mermaid.esm.min.mjs',
'packages/uib-md-utils/node_modules/mermaid/dist/mermaid.esm.min.mjs',
],
}
/** CSS build configuration.
* The source is the unminified brand CSS; the build produces a minified file and source map.
*/
const CSS_BUILD = {
name: 'uib-brand',
srcFile: `${FE_OUT}/uib-brand.css`,
outFile: `${FE_OUT}/uib-brand.min.css`,
mapFile: `${FE_OUT}/uib-brand.min.css.map`,
watchFiles: [
`${FE_OUT}/uib-brand.css`
],
}
// #endregion ---- Central Configuration ----
// #region ---- Version Helpers ----
/**
* Update the version string embedded in a source file according to the VERSION_FILES config.
*
* - 'semantic' type: replaces only the semver number portion (before '-src') with PKG_VERSION.
* - 'date' type: replaces a YYYY-MM-DD date with the source file's last modified date.
* If the regex contains a capture group it is treated as a fixed prefix and is preserved.
*
* Errors are non-fatal: a warning is logged and the function returns without throwing.
* @async
* @param {VersionFileEntry} entry - Version file configuration entry
* @returns {Promise<void>}
*/
async function updateVersionInSourceFile(entry) {
const filePath = join(ROOT, entry.file)
try {
const content = await readFile(filePath, 'utf8') // eslint-disable-line security/detect-non-literal-fs-filename
let updatedContent
if (entry.type === 'semantic') {
// Replace only the numeric semver part before '-src', preserving surrounding syntax
updatedContent = content.replace(entry.regex, (match) =>
match.replace(/\d+\.\d+\.\d+(?=-src)/, PKG_VERSION)
)
} else if (entry.type === 'date') {
const fileStat = await stat(filePath) // eslint-disable-line security/detect-non-literal-fs-filename
const fileDate = fileStat.mtime.toISOString().split('T')[0]
updatedContent = content.replace(entry.regex, (match, prefix) =>
// Use the capture group as a prefix when present (e.g. CSS @version comment)
(prefix !== undefined
? `${prefix}${fileDate}`
: match.replace(/\d{4}-\d{2}-\d{2}/, fileDate))
)
} else {
console.warn(`[version] Unknown type '${entry.type}' for ${entry.file}`)
return
}
if (updatedContent !== content) {
await writeFile(filePath, updatedContent, 'utf8') // eslint-disable-line security/detect-non-literal-fs-filename
console.log(`[version] Updated '${entry.type}' version in ${entry.file}`)
}
} catch (err) {
console.warn(`[version] Could not update ${entry.file}: ${err.message}`)
}
}
/**
* Replace the '-src' version suffix in a built output file with a format-specific suffix.
*
* For example, 'version = "7.7.0-src"' becomes 'version = "7.7.0-iife.min"' when
* called with suffix = 'iife.min'. The regex must contain exactly one capture group
* matching the full version string including the '-src' suffix.
*
* Errors are non-fatal: a warning is logged and the function returns without throwing.
* @async
* @param {string} filePath - Absolute path to the output file
* @param {RegExp} regex - Regex with one capture group matching the versioned substring
* @param {string} suffix - Format suffix to replace '-src' with (e.g. 'iife.min', 'esm', 'node')
* @returns {Promise<void>}
*/
async function updateVersionInOutputFile(filePath, regex, suffix) {
try {
const content = await readFile(filePath, 'utf8') // eslint-disable-line security/detect-non-literal-fs-filename
const updated = content.replace(regex, (match, ver) =>
match.replace(ver, ver.replace(/-src$/, `-${suffix}`))
)
if (updated !== content) {
await writeFile(filePath, updated, 'utf8') // eslint-disable-line security/detect-non-literal-fs-filename
}
} catch (err) {
console.warn(`[version] Could not update output version in ${filePath}: ${err.message}`)
}
}
/**
* Update all version strings across every entry in VERSION_FILES.
*
* Runs both 'semantic' and 'date' update types. This is equivalent to the
* pre-build version-stamp step but can be invoked standalone without triggering
* any esbuild or LightningCSS compilation.
* @async
* @returns {Promise<void>}
*/
async function updateAllVersions() {
await Promise.all(VERSION_FILES.map(e => updateVersionInSourceFile(e)))
console.log('[versions] All version strings updated.')
}
// #endregion ---- Version Helpers ----
// #region ---- Front-End Builds ----
/** Shared esbuild loader map for front-end builds — treats .mjs and .cjs as plain JS */
const FE_LOADER = { '.mjs': 'js', '.cjs': 'js', }
/** Build a single front-end module in all four standard output formats.
*
* Outputs produced (where `outBase` is the configured base path):
* {outBase}.iife.min.js IIFE, minified, with source map
* {outBase}.iife.js IIFE, unminified, no source map
* {outBase}.esm.min.js ESM, minified, with source map
* {outBase}.esm.js ESM, unminified, no source map
*
* If `config.nodeOut` is set, a fifth output (Node.js CJS bundle) is also produced.
* After each successful build the version suffix in the output file is updated from
* '-src' to the appropriate format-specific suffix (e.g. '-iife.min').
* @async
* @param {FEBuildConfig} config - Front-end build configuration
* @returns {Promise<void>}
* @throws {Error} If any individual sub-build fails
*/
async function buildFEModule(config) {
// Stamp the embedded version string in the source file before compiling
if (config.versionFile) {
const vEntry = VERSION_FILES.find(e => e.file === config.versionFile)
if (vEntry) await updateVersionInSourceFile(vEntry)
}
const entryPoint = join(ROOT, config.entryPoint)
const outBase = join(ROOT, config.outBase)
/** @type {import('esbuild').BuildOptions} Shared esbuild options for all four variants */
const common = {
entryPoints: [entryPoint],
bundle: true,
platform: 'browser',
loader: FE_LOADER,
target: ESBUILD_BROWSER_TARGETS,
supported: {
destructuring: true,
},
}
/**
* Build variants: [esbuild format, minify flag, output extension, version suffix].
* Source maps are generated only for minified builds.
* @type {Array<['iife'|'esm', boolean, string, string]>}
*/
const variants = [
['iife', true, 'iife.min.js', 'iife.min'],
['iife', false, 'iife.js', 'iife'],
['esm', true, 'esm.min.js', 'esm.min'],
['esm', false, 'esm.js', 'esm'],
]
for (const [format, minify, ext, versionSuffix] of variants) {
const outfile = `${outBase}.${ext}`
try {
await esbuild.build({ ...common, outfile, format, minify, sourcemap: minify, })
if (config.versionRegex) {
await updateVersionInOutputFile(outfile, config.versionRegex, versionSuffix)
}
} catch (err) {
throw new Error(
`[${config.name}] ${format.toUpperCase()} ${minify ? '(min)' : '(unmin)'} build failed: ${err.message}`
)
}
}
// Optionally produce a Node.js CJS build of the same module
if (config.nodeOut) {
const nodeOutfile = join(ROOT, config.nodeOut)
try {
await esbuild.build({
entryPoints: [entryPoint],
outfile: nodeOutfile,
bundle: true,
format: 'cjs',
platform: 'node',
minify: false,
sourcemap: false,
loader: FE_LOADER,
resolveExtensions: ['.mjs', '.cjs', '.js', '.ts', '.json'],
mainFields: ['module', 'main'],
external: [],
target: NODE_TARGET,
})
if (config.versionRegex) {
await updateVersionInOutputFile(nodeOutfile, config.versionRegex, 'node')
}
} catch (err) {
throw new Error(`[${config.name}] Node.js CJS build failed: ${err.message}`)
}
}
console.log(`[build] ✓ ${config.name}`)
}
/** Build the experimental front-end module as a single minified ESM file with a source map.
* This module is kept as a separate output because it may contain unstable or preview features.
* @async
* @returns {Promise<void>}
* @throws {Error} If the esbuild step fails
*/
async function buildExperimental() {
const config = EXPERIMENTAL_BUILD
const outfile = join(ROOT, config.outFile)
try {
await esbuild.build({
entryPoints: [join(ROOT, config.entryPoint)],
outfile,
bundle: true,
format: 'esm',
platform: 'browser',
minify: true,
sourcemap: true,
loader: FE_LOADER,
target: ESBUILD_BROWSER_TARGETS,
supported: {
destructuring: true,
},
})
} catch (err) {
throw new Error(`[${config.name}] ESM (min) build failed: ${err.message}`)
}
console.log(`[build] ✓ ${config.name}`)
}
/** Build all configured front-end modules and the experimental module concurrently.
* Individual failures are caught and reported; other builds continue unaffected.
* @async
* @returns {Promise<void>}
*/
async function buildAllFE() {
// markweb.mjs lives directly in FE_OUT and has no dedicated build entry;
// stamp its version string here whenever any FE build runs.
const markwebEntry = VERSION_FILES.find(e => e.file === `${FE_OUT}/utils/markweb.mjs`)
if (markwebEntry) await updateVersionInSourceFile(markwebEntry)
const tasks = [
...FE_BUILDS.map(cfg => buildFEModule(cfg)),
buildExperimental(),
buildMermaid(),
]
const results = await Promise.allSettled(tasks)
for (const result of results) {
if (result.status === 'rejected') {
console.error(`[build] ✗ ${result.reason}`)
}
}
}
// #endregion ---- Front-End Builds ----
// #region ---- Node.js Package Builds ----
/**
* Build a Node.js workspace package into both CJS (.cjs) and ESM (.mjs) formats.
* Code is NOT minified so that error messages and stack traces remain readable.
* Built-in Node.js modules are excluded automatically via platform:'node'.
* All package dependencies are bundled into the output for standalone use.
* @async
* @param {NodeBuildConfig} config - Node.js package build configuration
* @returns {Promise<void>}
* @throws {Error} If either sub-build fails
*/
async function buildNodePackage(config) {
const entryPoint = join(ROOT, config.entryPoint)
/** @type {import('esbuild').BuildOptions} Shared options for both CJS and ESM builds */
const common = {
entryPoints: [entryPoint],
bundle: true,
platform: 'node',
target: NODE_TARGET,
minify: false,
sourcemap: false,
loader: { '.mjs': 'js', },
}
try {
await esbuild.build({
...common,
format: 'cjs',
outfile: join(ROOT, `${config.outBase}.cjs`),
})
} catch (err) {
throw new Error(`[${config.name}] CJS build failed: ${err.message}`)
}
try {
await esbuild.build({
...common,
format: 'esm',
outfile: join(ROOT, `${config.outBase}.mjs`),
// CJS dependencies bundled into ESM use esbuild's __require shim, which throws
// at runtime because `require` is not defined in native ESM. Injecting a
// createRequire polyfill at the top of the bundle makes __require work correctly.
banner: { js: "import { createRequire } from 'module';\nconst require = createRequire(import.meta.url);\n", },
})
} catch (err) {
throw new Error(`[${config.name}] ESM build failed: ${err.message}`)
}
console.log(`[build] ✓ ${config.name} (CJS + ESM)`)
}
/**
* Build all configured Node.js packages concurrently.
* Individual failures are caught and reported; other builds continue unaffected.
* @async
* @returns {Promise<void>}
*/
async function buildAllNode() {
const results = await Promise.allSettled(NODE_BUILDS.map(cfg => buildNodePackage(cfg)))
for (const result of results) {
if (result.status === 'rejected') {
console.error(`[build] ✗ ${result.reason}`)
}
}
}
// #endregion ---- Node.js Package Builds ----
// #region ---- Mermaid Browser Bundle Build ----
/**
* Build mermaid as self-contained browser bundles (IIFE + ESM) by delegating to the
* uib-md-utils package build script. This ensures mermaid is resolved from the same
* node_modules tree used by the markdown utilities.
*
* Outputs:
* front-end/utils/mermaid.iife.min.js IIFE, minified
* front-end/utils/mermaid.esm.min.js ESM, minified
* @async
* @returns {Promise<void>}
*/
async function buildMermaid() {
/** Run a node script in a child process and reject if it exits non-zero.
* @param {string} scriptRelPath Relative script path from repository root
* @param {string[]} [scriptArgs] Optional CLI arguments for the script
* @returns {Promise<void>}
*/
const runNodeScript = (scriptRelPath, scriptArgs = []) => new Promise((resolve, reject) => {
const child = spawn(
process.execPath,
[join(ROOT, scriptRelPath), ...scriptArgs],
{ cwd: ROOT, stdio: 'inherit', }
)
child.on('close', (code) => {
if (code === 0) resolve()
else reject(new Error(`[${MERMAID_BUILD.name}] ${scriptRelPath} exited with code ${code}`))
})
child.on('error', reject)
})
// Keep uib-md-utils and the docs mermaid bundle aligned whenever mermaid changes.
await runNodeScript('packages/uib-md-utils/build.mjs')
await runNodeScript('src/doc-bundle/build.mjs', ['--mermaid-only'])
}
// #endregion ---- Mermaid Browser Bundle Build ----
/**
* Build and minify the uib-brand CSS file using LightningCSS.
*
* Steps performed:
* 1. Update the @version date comment in the source file to source file's last modified date.
* 2. Read the (potentially updated) source file.
* 3. Transform with LightningCSS: minify and apply browser-specific transforms.
* 4. Write the minified CSS with an appended sourceMappingURL comment.
* 5. Write the source map file.
*
* Both output files ({outFile} and {mapFile}) are created or overwritten.
* @async
* @returns {Promise<void>}
*/
async function buildCSS() {
const config = CSS_BUILD
const srcPath = join(ROOT, config.srcFile)
const outPath = join(ROOT, config.outFile)
const mapFileName = basename(config.mapFile)
// Step 1: Update the @version date in the CSS source via the centralised VERSION_FILES config
const cssVersionEntry = VERSION_FILES.find(e => e.file === config.srcFile)
if (cssVersionEntry) {
await updateVersionInSourceFile(cssVersionEntry)
}
// Step 2: Read source (after potential date update)
let cssInput
try {
cssInput = await readFile(srcPath) // eslint-disable-line security/detect-non-literal-fs-filename
} catch (err) {
console.error(`[css] Cannot read ${config.srcFile}: ${err.message}`)
return
}
// Steps 3-5: Transform and write output
try {
const { code, map, } = transform({
filename: config.srcFile,
code: cssInput,
minify: true,
sourceMap: true,
targets: LIGHTNING_TARGETS,
})
// Append sourceMappingURL so browser DevTools can locate the map
const sourceMapComment = `\n/*# sourceMappingURL=${mapFileName} */\n`
await writeFile(outPath, Buffer.concat([code, Buffer.from(sourceMapComment)])) // eslint-disable-line security/detect-non-literal-fs-filename
if (map) {
await writeFile(join(ROOT, config.mapFile), map) // eslint-disable-line security/detect-non-literal-fs-filename
}
console.log(`[build] ✓ ${config.name} CSS`)
} catch (err) {
console.error(`[build] ✗ CSS build failed: ${err.message}`)
}
}
// #endregion ---- CSS Build ----
// #region ---- Docs Bundle Build ----
/** Build the Docsify documentation bundle for offline use.
* Delegates to the existing src/doc-bundle/build.mjs script by spawning a child process so
* that the doc bundle's top-level await and side effects are fully isolated.
* @async
* @returns {Promise<void>}
* @throws {Error} If the child process exits with a non-zero code
*/
async function buildDocBundle() {
console.log('[docs] Building documentation bundle...')
return new Promise((resolve, reject) => {
const child = spawn(
process.execPath,
[join(ROOT, 'src/doc-bundle/build.mjs')],
{ cwd: ROOT, stdio: 'inherit', }
)
child.on('close', (code) => {
if (code === 0) {
console.log('[build] ✓ docs bundle')
resolve()
} else {
reject(new Error(`[docs] Doc bundle build exited with code ${code}`))
}
})
child.on('error', reject)
})
}
// #endregion ---- Docs Bundle Build ----
// #region ---- Git Tag ----
/**
* Create and push a GitHub tag for the current release version.
*
* Reads the current version from {@link PKG_VERSION} and the most-recent git tag via
* `git describe --tags --abbrev=0`. If the versions differ, a new tag `v{PKG_VERSION}`
* is created locally and pushed to the remote with `--follow-tags` and `origin --tags`.
* No-op (with an informational log) when the tag already exists.
* @async
* @returns {Promise<void>}
*/
async function createGitTag() {
let lastTag = ''
try {
const { stdout, } = await execFileAsync('git', ['describe', '--tags', '--abbrev=0'])
lastTag = stdout.trim()
} catch (_err) {
// No tags exist yet — treat as empty string
}
console.log(`[tag] Last committed tag : ${lastTag || '(none)'}`)
console.log(`[tag] Current version : v${PKG_VERSION}`)
if (lastTag.replace(/^v/, '') === PKG_VERSION) {
console.log('[tag] Tag already exists — nothing to do.')
return
}
const tagName = `v${PKG_VERSION}`
console.log(`[tag] Creating tag ${tagName} ...`)
await execFileAsync('git', ['tag', tagName])
await execFileAsync('git', ['push', '--follow-tags'])
await execFileAsync('git', ['push', 'origin', '--tags'])
console.log(`[tag] ✓ Tag ${tagName} created and pushed.`)
}
// #endregion ---- Git Tag ----
// #region ---- Watch Mode ----
/**
* Execute an async build function and report any error without crashing the watcher process.
* This ensures a single failing rebuild does not stop other modules from being watched.
* @async
* @param {() => Promise<void>} buildFn - Async build function to execute safely
* @param {string} label - Human-readable name used in error output
* @returns {Promise<void>}
*/
async function safeRebuild(buildFn, label) {
try {
await buildFn()
} catch (err) {
console.error(`[watch] ✗ ${label} rebuild failed: ${err.message}`)
}
}
/**
* Start chokidar file watchers for all configured build targets.
* Each module has its own dedicated watcher so only the affected output(s) are rebuilt
* when a source file changes. No initial build is performed — only file changes trigger builds.
* @async
* @returns {Promise<void>}
*/
async function startWatch() {
console.log('\n[watch] Starting watch mode...\n')
// Lazily import chokidar only when watch mode is actually used.
// The uib-fs-utils bundle uses dynamic require() which fails at module parse
// time in a pure ESM context, so a top-level static import would break every
// non-watch invocation (e.g. `node bin/build.mjs versions`).
const { chokidar, } = await import('../packages/uib-fs-utils/index.mjs')
// Chokidar v5 removed glob pattern support — it only accepts real filesystem paths.
// picomatch is used to filter change events against the original patterns.
const { default: picomatch, } = await import('picomatch') // eslint-disable-line n/no-extraneous-import
/**
* Strip the project root from a path for cleaner log lines.
* @param {string} p - Absolute file path
* @returns {string} Input path with the project root removed, for concise logging
*/
const rel = (p) => p.replace(ROOT + '\\', '').replace(ROOT + '/')
/**
* Normalise a path to forward slashes for consistent cross-platform comparisons.
* @param {string} p - Path, potentially containing backslashes
* @returns {string} Path with all backslashes replaced by forward slashes
*/
const toFwdSlash = (p) => p.replace(/\\/g, '/')
/** Regex that matches any glob special character */
const GLOB_CHARS_RE = /[*?[\]{!@+]/
/**
* Resolve an array of watchFiles patterns into chokidar-compatible filesystem paths
* and a combined picomatch filter function.
*
* Chokidar v5 no longer resolves glob patterns — passing a pattern like
* `src/**\/*.mjs` results in an empty watcher. Instead we watch the deepest
* concrete ancestor directory of each glob pattern so chokidar picks up all
* descendant changes, then filter emitted paths with picomatch to avoid
* spurious rebuilds from unrelated files in the same directory.
*
* @param {string[]} patterns - Relative watchFiles pattern strings
* @returns {{ watchPaths: string[], isMatch: (p: string) => boolean }} Resolved watch paths and a combined pattern matcher
*/
function resolveWatchEntries(patterns) {
/** @type {string[]} */
const watchPaths = []
/** @type {Array<(p: string) => boolean>} */
const matchers = []
for (const pattern of patterns) {
const absPattern = toFwdSlash(join(ROOT, pattern))
if (GLOB_CHARS_RE.test(pattern)) {
// Glob pattern: derive the base directory (everything before the first glob char)
const globIdx = absPattern.search(GLOB_CHARS_RE)
const baseDir = absPattern.slice(0, absPattern.lastIndexOf('/', globIdx))
watchPaths.push(baseDir)
matchers.push(picomatch(absPattern))
} else {
// Plain file path: watch directly; match by exact normalised path
watchPaths.push(absPattern)
matchers.push((p) => toFwdSlash(p) === absPattern)
}
}
const isMatch = (p) => matchers.some(m => m(toFwdSlash(p)))
return { watchPaths, isMatch, }
}
// ── Front-end modules (one watcher per config) ──────────────────────
for (const config of FE_BUILDS) {
const { watchPaths, isMatch, } = resolveWatchEntries(config.watchFiles)
chokidar
.watch(watchPaths, { ignoreInitial: true, })
.on('change', (p) => {
if (!isMatch(p)) return
console.log(`[watch] Changed: ${rel(p)}`)
safeRebuild(() => buildFEModule(config), config.name)
})
.on('add', (p) => {
if (!isMatch(p)) return
console.log(`[watch] Added: ${rel(p)}`)
safeRebuild(() => buildFEModule(config), config.name)
})
}
// ── Experimental module ──────────────────────────────────────────────
{
const { watchPaths, isMatch, } = resolveWatchEntries(EXPERIMENTAL_BUILD.watchFiles)
chokidar
.watch(watchPaths, { ignoreInitial: true, })
.on('change', (p) => {
if (!isMatch(p)) return
console.log(`[watch] Changed: ${rel(p)}`)
safeRebuild(buildExperimental, EXPERIMENTAL_BUILD.name)
})
}
// ── Node.js packages (one watcher per config) ────────────────────────
for (const config of NODE_BUILDS) {
const { watchPaths, isMatch, } = resolveWatchEntries(config.watchFiles)
chokidar
.watch(watchPaths, { ignoreInitial: true, })
.on('change', (p) => {
if (!isMatch(p)) return
console.log(`[watch] Changed: ${rel(p)}`)
safeRebuild(() => buildNodePackage(config), config.name)
})
.on('add', (p) => {
if (!isMatch(p)) return
console.log(`[watch] Added: ${rel(p)}`)
safeRebuild(() => buildNodePackage(config), config.name)
})
}
// ── CSS source ───────────────────────────────────────────────────────
{
const { watchPaths, isMatch, } = resolveWatchEntries(CSS_BUILD.watchFiles)
chokidar
.watch(watchPaths, { ignoreInitial: true, })
.on('change', (p) => {
if (!isMatch(p)) return
console.log(`[watch] Changed: ${rel(p)}`)
safeRebuild(buildCSS, CSS_BUILD.name)
})
}
// ── Mermaid browser bundle (rebuilds when mermaid dist is updated) ───
{
// Watch whichever mermaid dist file actually exists on disk
const candidates = MERMAID_BUILD.watchFiles.map(f => join(ROOT, f)).filter(f => existsSync(f))
if (candidates.length > 0) {
chokidar
.watch(candidates, { ignoreInitial: true, })
.on('change', (p) => {
console.log(`[watch] mermaid updated: ${rel(p)}`)
safeRebuild(buildMermaid, MERMAID_BUILD.name)
})
}
}
// ── package.json — refresh PKG_VERSION when version is bumped ────────────
{
const pkgJsonPath = join(ROOT, 'package.json')
chokidar
.watch(pkgJsonPath, { ignoreInitial: true, })
.on('change', async () => {
try {
const newVersion = JSON.parse(await readFile(pkgJsonPath, 'utf8')).version // eslint-disable-line security/detect-non-literal-fs-filename
if (newVersion !== PKG_VERSION) {
PKG_VERSION = newVersion
console.log(`[watch] package.json version → ${PKG_VERSION} — re-stamping version files`)
await safeRebuild(updateAllVersions, 'version stamps')
// markweb.mjs has no esbuild step so it is never re-stamped by a FE build.
// Stamp it explicitly here so a running watch always keeps it current,
// even if the VERSION_FILES entry was added after this process started.
await updateVersionInSourceFile({ file: `${FE_OUT}/utils/markweb.mjs`, regex: /version = '[\d.]+-src'/, type: 'semantic', })
}
} catch (err) {
console.warn(`[watch] Could not refresh version from package.json: ${err.message}`)
}
})
}
console.log('[watch] Watching for changes. Press Ctrl+C to stop.\n')
}
// #endregion ---- Watch Mode ----
// #region ---- CLI Entry Point ----
/**
* Parse process.argv to determine which build targets to run and whether to enable watch mode.
*
* Flags: --watch | -w Skip initial build; enter watch mode immediately (only changed files are rebuilt)
* Targets (positional args, any combination):
* all Build everything (default when no positional arg is given)
* fe Front-end modules
* node Node.js workspace packages
* css CSS files
* docs Docsify documentation bundle
* versions Update all version strings without building
* tag Create and push a GitHub release tag for the current version
*
* @returns {{ targets: string[], watch: boolean }} Parsed build targets and watch mode flag
*/
function parseArgs() {
const args = process.argv.slice(2)
const watchMode = args.includes('--watch') || args.includes('-w')
const positional = args.filter(a => !a.startsWith('-'))
const targets = positional.length > 0 ? positional : ['all']
return { targets, watch: watchMode, }
}
/**
* Main entry point.
*
* 1. Parses CLI arguments.
* 2. Prints a startup banner with version and target information.
* 3. Runs the selected builds concurrently (skipped in watch mode).
* Version strings are stamped inside each build function, just before compilation.
* 4. Optionally enters watch mode; only changed files trigger a rebuild (no initial build).
* @async
* @returns {Promise<void>}
*/
async function main() {
const { targets, watch, } = parseArgs()
const buildAll = targets.includes('all')
const versionsOnly = targets.length === 1 && targets.includes('versions')
const buildFE = buildAll || targets.includes('fe')
const buildNode = buildAll || targets.includes('node')
const buildCss = buildAll || targets.includes('css')
const buildDocs = buildAll || targets.includes('docs')
const updateVersions = targets.includes('versions')
const tagOnly = targets.length === 1 && targets.includes('tag')
const buildTagTarget = targets.includes('tag')
// Named FE module builds (e.g. `node bin/build.mjs json-viewer`)
const namedFEBuilds = targets.filter(t => FE_BUILDS.some(cfg => cfg.name === t))
// ── Startup banner ───────────────────────────────────────────────────
const SEP = '─'.repeat(60)
console.log(SEP)
console.log(' uibuilder build script')
console.log(` Package version : ${PKG_VERSION}`)
console.log(` Node.js target : ${NODE_TARGET} (Node-RED ≥v3 baseline)`)
console.log(` Browser target : ${ESBUILD_BROWSER_TARGETS_HARDCODED.join(', ')}`)
console.log(` Targets : ${targets.join(', ')}${watch ? ' [watch mode]' : ''}`)
console.log(SEP)
console.log()
// ── Standalone versions-only target ─────────────────────────────────
if (versionsOnly || updateVersions) {
await updateAllVersions()
if (versionsOnly) {
console.log('\n[build] Done.')
return
}
console.log()
}
// ── Standalone tag-only target ───────────────────────────────────────
if (tagOnly) {
await createGitTag()
console.log('\n[build] Done.')
return
}
// ── Run selected builds (skipped entirely in watch mode — only changes trigger builds) ──
if (!watch) {
/** @type {Array<[string, () => Promise<void>]>} */
const tasks = []
if (buildFE) tasks.push(['front-end modules', buildAllFE])
if (buildNode) tasks.push(['node packages', buildAllNode])
if (buildCss) tasks.push(['CSS', buildCSS])
if (buildDocs) tasks.push(['docs bundle', buildDocBundle])
// 'tag' is intentionally excluded from 'all' — must be invoked explicitly
if (buildTagTarget) tasks.push(['git tag', createGitTag])
// Individual named FE module builds (e.g. `node bin/build.mjs json-viewer`)
if (!buildFE && namedFEBuilds.length > 0) {
for (const name of namedFEBuilds) {
const cfg = FE_BUILDS.find(c => c.name === name)
tasks.push([cfg.name, () => buildFEModule(cfg)])
}
}
if (tasks.length === 0) {
console.warn('[build] No recognised build targets. Use: all | fe | node | css | docs | <module-name>')
return
}
// Run all tasks concurrently; individual failures are caught inside each function
const results = await Promise.allSettled(tasks.map(([, fn]) => fn()))
for (let i = 0; i < results.length; i++) {
if (results[i].status === 'rejected') {
console.error(`\n[main] ✗ ${tasks[i][0]}: ${results[i].reason}`)
}
}
console.log('\n[build] Build complete.')
}
// ── Optionally enter watch mode ──────────────────────────────────────
if (watch) {
await startWatch()
}
}
main().catch(err => {
console.error(`[fatal] Uncaught error: ${err.message}`)
process.exitCode = 1 // Use exitCode instead of exit() to allow pending logs to flush
})
// #endregion ---- CLI Entry Point ----