/* ESP8266 file system builder Copyright (C) 2016-2019 by Xose Pérez Copyright (C) 2019-2024 by Maxim Prokhorov This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . */ // ----------------------------------------------------------------------------- // Dependencies // ----------------------------------------------------------------------------- import { dest as orig_dest, series, parallel, src, } from 'gulp'; import { rollup } from 'rollup'; import { default as rollupEsbuild } from 'rollup-plugin-esbuild'; import { default as rollupVirtual } from '@rollup/plugin-virtual'; import { default as rollupAlias } from '@rollup/plugin-alias'; import { minify as htmlMinify } from 'html-minifier-terser'; import { JSDOM } from 'jsdom'; import { removeMapFileComments } from 'convert-source-map'; import log from 'fancy-log'; import { Transform } from 'node:stream'; import { escape as queryEscape } from 'node:querystring'; import { parseArgs } from 'node:util'; import { pipeline } from 'node:stream/promises'; import * as fs from 'node:fs'; import * as http from 'node:http'; import * as path from 'node:path'; import * as url from 'node:url'; import * as zlib from 'node:zlib'; import { maybeInline, needElement, stripModules as stripModulesImpl, } from './html/inline.mjs'; import { MODULE_PRESETS, MODULE_DEV, build as buildPresets, makeModules, } from './html/preset.mjs'; // ----------------------------------------------------------------------------- // Configuration // ----------------------------------------------------------------------------- /** * @import { default as File } from 'vinyl' */ /** * module names used internally, usually as element class-name * @typedef {{[k: string]: boolean}} Modules */ /** * name mapping for output filename, when specific module is used * @typedef {{[k: string]: string}} NamedBuild */ /** * helper functions that deal with 'module' elements * @typedef {function(JSDOM): (boolean | Promise)} HtmlModify */ /** * resulting blob compression type * @typedef {'br' | 'gz'} Compression */ /** * build pipeline common options * @typedef BuildOptions * @property {string} name * @property {boolean} minify * @property {Compression} compress * @property {Modules} modules */ /** * shared pipeline file stats, set up to figure out 'latest' mtime for the resulting file * @typedef {{[k: string]: fs.Stats}} BuildStats */ // directory where this file is located export const ROOT = path.dirname(url.fileURLToPath(import.meta.url)); // vendored sources from node_modules/ need explicit paths export const NODE_DIR = path.join(ROOT, 'node_modules'); // ui sources root export const HTML_DIR = path.join(ROOT, 'html'); // build preset environment files export const PRESET_DIR = path.join(HTML_DIR, 'preset'); // output .html w/ inline sourcemaps (for development only) // output .html.{gz,br}, cleaned-up for firmware use export const BUILD_DIR = path.join(HTML_DIR, 'build'); // vendored sources, usually injected as-is without any minification or compression export const VENDOR_DIR = path.join(HTML_DIR, 'vendor'); // input sources, making sure relative inline paths start from here export const SRC_DIR = path.join(HTML_DIR, 'src'); // spec aka test files, make sure only these are used when running tests export const SPEC_DIR = path.join(HTML_DIR, 'spec'); // main source file used by inline-source export const ENTRYPOINT = path.join(HTML_DIR, 'index.html') // .ipp compiled from the .html.{br,gz}, providing static u8[] for the firmware to use export const STATIC_DIR = path.join(ROOT, 'espurna', 'static'); // importmap manifest for dev server. atm, explicit overrides list // based on known locations for the MODULE_DEV preset export const IMPORT_MAP = { '@jaames/iro': path.join(NODE_DIR, '/@jaames/iro/dist/iro.es.js'), '@build-preset/constants.mjs': path.join(PRESET_DIR, MODULE_DEV, 'constants.mjs'), }; // files relevant to the build export const BUILD_SCRIPTS = [ 'eslint.config.mjs', 'gulpfile.mjs', 'vite.config.mjs', 'vitest.config.mjs', `${HTML_DIR}/*.mjs`, `${PRESET_DIR}/**/*.mjs`, ]; // files relevant to the vitest export const TEST_SCRIPTS = [ `${SPEC_DIR}/**/*.mjs`, ]; // files relevant to the webui export const SOURCE_SCRIPTS = [ `${SRC_DIR}/**/*.mjs`, ]; export const SOURCE_HTML = [ `${SRC_DIR}/**/*.html`, ]; // dev server lives on localhost by default; note that it accepts one-of 127.0.0.1 or ::1 const DEV_HOST = 'localhost'; const DEV_PORT = 8080; // MODULE_DEV is a special case, not intended for the writtable output const MODULE_BUILD_PRESETS = new Set( Array.from(MODULE_PRESETS) .filter((x) => x !== MODULE_DEV)); // ----------------------------------------------------------------------------- // Build // ----------------------------------------------------------------------------- /** * build pipeline usually works with file inputs and its transformations * main function to compose all these separate tasks is `stream.pipeline` * - https://nodejs.org/api/stream.html#streampipelinesource-transforms-destination-options * * transformations most commonly happen on vynil-fs `File` objects, which means `{objectMode: true}` * should always be set for any new stream; per https://nodejs.org/api/stream.html#object-mode * > All streams created by Node.js APIs operate exclusively on strings, , and objects * (nb. async generator functions do not seem to care, though, only stream Writable/Transform classes) * * while gulp documentation recommends using `src.pipe(dst)` as a general pattern, and error handling * *may* just work correctly for 'streamx'-originated readers / writers... it breaks for 'node:stream' * - https://github.com/gulpjs/gulp/issues/359 * - https://github.com/gulpjs/gulp/issues/2812 * - https://github.com/gulpjs/gulp/issues/2812#issuecomment-2445451930 * * also note that any `Error` in the writer / transformer function has to pass through the 'callback(...)' * otherwise, exception would be raised from the pipeline internal task and its origin may not be obvious */ const ERR_CONTENTS_TYPE = new Error('expecting source contents to be a buffer!'); const ERR_EMPTY = new Error('source contents cannot be empty'); const ERR_SINGLE_BUNDLE = new Error('js bundle only supports a single output'); /** * after destination finishes, log everything written so far * @param {string} dstdir) */ function dest(dstdir) { const out = orig_dest(dstdir); out.on('data', data); out.on('finish', finish); let srcpath = ''; let size = 0; /** @param {File} source */ function data(source) { srcpath = source.path; if (source.isBuffer()) { size = source.contents.length; } }; function finish() { if (!srcpath || !size) { return; } const name = path.relative(ROOT, srcpath); if (srcpath.startsWith(BUILD_DIR)) { log(`${name}: ${size} bytes`); } else { log(`written ${name}`); } } return out; } /** * @param {import("html-minifier-terser").Options} options * @returns {Transform} */ function toMinifiedHtml(options) { return new Transform({ objectMode: true, async transform(source, _, callback) { if (!source.contents) { callback(ERR_EMPTY); return; } const contents = source.contents.toString(); const minified = await htmlMinify(contents, options); source.contents = Buffer.from(minified); callback(null, source); }}); } const ERR_COMPRESSION = new Error('unknown compression type'); /** * @param {BuildOptions} options * @returns {Transform} */ function toCompressed(options) { return new Transform({ objectMode: true, transform(/** @type {File} */source, _, callback) { if (!(source.contents instanceof Buffer)) { callback(ERR_CONTENTS_TYPE); return; } if (!options.compress) { callback(null, source.contents); return; } /** * gzip inserts an OS-dependant byte in the header, ref. * - https://datatracker.ietf.org/doc/html/rfc1952 * - https://github.com/nodejs/node/blob/e46c680bf2b211bbd52cf959ca17ee98c7f657f5/deps/zlib/deflate.c#L901 * - windowBits description in the https://zlib.net/manual.html#Advanced * * python3.14+ sets this to 255 (0xff), so is this handler * - https://github.com/python/cpython/pull/120486 * - https://github.com/python/cpython/commit/08d09cf5ba041c9c5c3860200b56bab66fd44a23 * * additionally, set mtime to 0 (in case it ever comes up) * - https://github.com/python/cpython/pull/125261 * - https://github.com/python/cpython/commit/dcd58c50844dae0d83517e88518a677914ea594b * * @param {Buffer} buf * @returns {Buffer} */ function normalize(buf) { if (options.compress === 'gz') { // mtime buf[4] = 0; buf[5] = 0; buf[6] = 0; buf[7] = 0; // os buf[9] = 0xff; } return buf; } /** @type {zlib.CompressCallback} */ function compress_callback(error, result) { if (!error) { source.contents = normalize(result); source.extname += `.${options.compress}`; callback(null, source); } else { callback(error); } }; switch (options.compress) { case 'br': zlib.brotliCompress( source.contents.buffer, {params: { [zlib.constants.BROTLI_PARAM_QUALITY]: zlib.constants.BROTLI_MAX_QUALITY} }, compress_callback); break; case 'gz': zlib.gzip( source.contents.buffer, {level: zlib.constants.Z_BEST_COMPRESSION}, compress_callback); break; default: callback(ERR_COMPRESSION); break; } }}); } /** * @param {Buffer} buffer * @param {number} every * @returns {string} */ function formatBufferLines(buffer, every = 20) { /** @type {string[]} */ let lines = []; for (let i = 0; i < buffer.length; i += every) { lines.push( Array.from(buffer.subarray(i, i + every)) .map((x) => '0x' + x.toString(16).padStart(2, '0')) .join(',')); } return lines.join(',\n'); } /** * @param {File} file * @returns {string} */ function formatLastModified(file) { return (file?.stat?.mtime ?? (new Date())).toUTCString() } /** * @param {Compression} compress * @returns {string} */ function formatContentEncoding(compress) { switch (compress) { case 'br': return 'br'; case 'gz': return 'gzip'; default: throw ERR_COMPRESSION; } } /** * generates c++-friendly output from the stream contents * @param {BuildOptions} options * @returns {Transform} */ function toOutput(options) { return new Transform({ objectMode: true, transform(/** @type {File} */source, _, callback) { if (!(source.contents instanceof Buffer)) { callback(ERR_CONTENTS_TYPE); return; } // make sure to include both type and data const output = [ '#pragma once', '#include ', '#include ', `alignas(4) static constexpr char webui_last_modified[] PROGMEM = "${formatLastModified(source)}";`, `alignas(4) static constexpr char webui_content_encoding[] PROGMEM = "${formatContentEncoding(options.compress)}";`, 'alignas(4) static constexpr uint8_t webui_data[] PROGMEM = {', formatBufferLines(source.contents), '};\n' ]; // resulting file ext hides the compression option const extname = '.ipp'; // replace source stream with a different one, also replacing contents const dest = source.clone({contents: false}); dest.contents = Buffer.from(output.join('\n')); switch (dest.extname) { case `.${options.compress}`: dest.extname = extname; break; default: dest.extname += extname; break; } callback(null, dest); }}); } /** * @param {BuildStats} stats * @returns {Transform} */ function trackFileStats(stats) { return new Transform({ objectMode: true, transform(source, _, callback) { if (source.stat) { stats[path.relative('.', source.path)] = source.stat; } callback(null, source); }}); } /** * by default, destination preserves stat.*time of the source. which is obviosly bogus here as gulp * only knows about the entrypoint and not about every include happenning through inline-source * @param {BuildStats} stats * @returns {Transform} */ function adjustFileStats(stats) { return new Transform({ objectMode: true, transform(source, _, callback) { const values = Object.values(stats); if (values.length) { const latest = Object.values(stats) .reduce((prev, x) => { if (prev.mtime.valueOf() > x.mtime.valueOf()) { return prev; } return x; }); source.stat = latest; } callback(null, source); }}); } /** * updates source filename to a different one * @param {string} name * @returns {Transform} */ function rename(name) { return new Transform({ objectMode: true, transform(source, _, callback) { const out = source.clone({contents: false}); out.path = path.join(out.base, name); if (out.sourceMap) { out.sourceMap.file = out.relative; } callback(null, out); }}); } /** * @param {HtmlModify[]} handlers * @returns {Transform} */ function modifyHtml(handlers) { return new Transform({ objectMode: true, async transform(source, _, callback) { if (!(source.contents instanceof Buffer)) { callback(ERR_CONTENTS_TYPE); return; } const dom = new JSDOM(source.contents, {includeNodeLocations: true}); let results = []; for (const handler of handlers) { results.push(await Promise.resolve(handler(dom))); } if (results.some((x) => x)) { source.contents = Buffer.from(dom.serialize()); } callback(null, source); }}); } /** * when minification is enabled, make sure sourcemaps are not written to the resulting file * @returns {HtmlModify} */ function dropSourcemap() { return function(dom) { const scripts = dom.window.document.getElementsByTagName('script'); for (let script of scripts) { if (script.getAttribute('type') === 'importmap') { continue; } if (!script.textContent) { continue; } script.textContent = removeMapFileComments(script.textContent); return true; } return false; } } /** * optionally inject external libs paths * @param {boolean} minify * @returns {HtmlModify} */ function injectVendor(minify) { return function(dom) { if (minify) { return false; } const script = dom.window.document.getElementsByTagName('script'); const importmap = dom.window.document.createElement('script'); importmap.setAttribute('type', 'importmap'); const imports = Object.fromEntries( Object.keys(IMPORT_MAP).map((key) => [key, `./${key}`])); importmap.textContent = JSON.stringify({imports}); const head = dom.window.document.getElementsByTagName('head')[0]; head.insertBefore(importmap, script[0]); return true; } } /** * generally a good idea to mitigate "tab-napping" attacks * per https://www.chromestatus.com/feature/6140064063029248 * @returns {HtmlModify} */ function externalBlank() { return function(dom) { let changed = false; for (let elem of dom.window.document.getElementsByTagName('a')) { if (elem.href.startsWith('http')) { elem.setAttribute('target', '_blank'); elem.setAttribute('rel', 'noopener'); elem.setAttribute('tabindex', '-1'); changed = true; } } return changed; } } /** * with an explicit list of modules, strip anything not used by the bundle * @param {Modules} modules * @returns {HtmlModify} */ function stripModules(modules) { return function(dom) { return stripModulesImpl(dom, modules); }; } /** * inline and render index.html from the source template * expected to be called before any other html parsing happens * * @param {BuildOptions} options * @param {BuildStats} stats * @returns {HtmlModify} */ function makeIndexHtml(options, stats) { return async function(dom) { let changed = false; for (const elem of dom.window.document.querySelectorAll('inline-source')) { let src = elem.getAttribute('src'); if (!src) { continue; } if (path.isAbsolute(src)) { src = src.slice(1); } src = path.join(HTML_DIR, src); stats[src] = await fs.promises.stat(src); if (!needElement(elem, options.modules)) { elem.parentElement?.removeChild(elem); changed = true; continue; } const data = await fs.promises.readFile(src); elem.outerHTML = data.toString(); changed = true; } return changed; }; } /** * make an explicit list of all available imports, even the ones using uncommon names * @param {string} preset */ function makeImportAlias(preset) { return [ {find: '/vendor', replacement: VENDOR_DIR}, {find: '@build-preset', replacement: path.join(PRESET_DIR, preset)}, {find: '@jaames/iro', replacement: IMPORT_MAP['@jaames/iro']}, ]; } /** * Inline every external resource into the entrypoint html. * Works outside of gulp context, track everything that passes through manually. * * nb. '@build-preset' consts module does not work properly w/ esbuild --bundle * Instead of creating noop statements when module is disabled, unused import * would still be injected into the resulting bundle. * * Rollup bundling prevents this from happening, allowing external consts file. * Previous implementation used 'globals' / 'define' as a workaround. * * ref. https://github.com/evanw/esbuild/issues/1420 * ref. https://github.com/evanw/esbuild/issues/1895 * ref. html/inline.mjs`forceNoSideEffects()` * * @param {BuildStats} stats * @param {BuildOptions} options * @returns {HtmlModify} */ function makeInlineSource(options, stats) { /** * @param {Buffer} code * @param {string} type */ function encode(code, type) { const encoded = type.includes('base64') ? code.toString('base64') : queryEscape(code.toString()); return `data:${type},${encoded}`; } /** * @param {string} src * @param {string} tag * @param {string} type */ async function load(src, tag, type) { const code = await fs.promises.readFile(src); if (tag !== 'SCRIPT' && type) { return encode(code, type); } switch (tag) { // by default, expect urlquotable svg. override type=... when it is not case 'LINK': return encode(code, 'image/svg+xml'); // current build always produces a single entrypoint, minify asap case 'SCRIPT': if (!options.minify) { return code.toString(); } const result = await rollup({ input: src, treeshake: { moduleSideEffects: () => false, }, plugins: [ rollupAlias({ entries: makeImportAlias(options.name), }), rollupVirtual({ src: code.toString(), }), rollupEsbuild({ platform: 'browser', target: 'es2022', minify: true, format: 'esm', }), ], }); const bundle = await result.generate({ sourcemap: 'inline', }); if (bundle.output.length !== 1) { throw ERR_SINGLE_BUNDLE; } return bundle.output[0].code; } return code.toString(); } /** * @param {string} src */ function resolve(src) { if (src.startsWith('/')) { src = src.slice(1); } src = path.join(HTML_DIR, src); // prevent '?...' query params from appearing, // vite assets would sometimes use '?inline' const asUrl = new URL(`file:///${src}`); for (const [param] of asUrl.searchParams) { asUrl.searchParams.delete(param); } return url.fileURLToPath(asUrl.href); } return async function(dom) { let changed = false; for (const elem of dom.window.document.querySelectorAll('link,script')) { await maybeInline(dom, elem, { load, resolve, async post(src) { stats[src] = await fs.promises.stat(src); changed = true; } }); } return changed; }; } /** * @param {string} lhs * @param {string} rhs * @returns {Transform} */ function replace(lhs, rhs) { return new Transform({ objectMode: true, transform(source, _, callback) { if (!(source.contents instanceof Buffer)) { callback(ERR_CONTENTS_TYPE); return; } const before = source.contents.toString(); source.contents = Buffer.from(before.replaceAll(lhs, rhs)); callback(null, source); }}); } /** * @param {BuildOptions} options */ function buildHtml(options) { /** @type {BuildStats} */ const stats = {}; const out = [ src(ENTRYPOINT), trackFileStats(stats), modifyHtml([ makeIndexHtml(options, stats), makeInlineSource(options, stats), injectVendor(options.minify), stripModules(options.modules), externalBlank(), ]), adjustFileStats(stats), ]; if (options.minify) { out.push( toMinifiedHtml({ collapseWhitespace: true, removeComments: true, minifyCSS: true, minifyJS: false }), replace('pure-', 'p-')); } return out; } /** * @param {BuildOptions} options */ function buildOutputs(options) { const out = [ rename(`index.${options.name}.html`), dest(BUILD_DIR), modifyHtml([ dropSourcemap(), ]), ]; if (options.compress) { out.push( toCompressed(options), dest(BUILD_DIR)); } out.push( toOutput(options), dest(STATIC_DIR)); return out; } /** * @param {string} name */ function buildWebUI(name) { /** @type {BuildOptions} */ const opts = { compress: 'gz', minify: true, modules: makeModules(name), name: name, }; return pipeline([ ...buildHtml(opts), ...buildOutputs(opts), ]); } /** @typedef ServeOptions * @property {string} name * @property {string} host * @property {number} port * * @param {ServeOptions} options */ function serveWebUI({name, host, port}) { const server = http.createServer(); /** @param {any} e */ function log_error(e) { if (e instanceof Error) { log.error(e.message); } else { log.error(e); } } /** * @param {http.ServerResponse} response * @param {string} path */ async function responseJsFile(response, path) { const reader = fs.createReadStream(path); // by default, status 200 would be sent out w/ the data response.setHeader('Content-Type', 'application/javascript'); // assume that 'readable' already sent status and headers, error handler only cares about 'open' reader.once('error', (e) => { if (('syscall' in e) && (typeof e.syscall === 'string') && e.syscall === 'open') { const outer = e; try { response.writeHead(404, { 'Content-Type': 'text/plain', }); response.end('not found'); } catch (e) { log_error(outer); log_error(e); } } else { log_error(e); } }); try { await pipeline(reader, response); } catch (e) { log_error(e); } } /** * @param {http.ServerResponse} response */ async function responseIndex(response) { response.setHeader('Content-Type', 'text/html'); try { await pipeline( /** @ts-ignore, types/node/stream/promises/pipeline.d.ts hates 'args' / '...args' */ ...buildHtml({name, modules: makeModules(name), compress: false, minify: false}), // convert the original vinyl-fs stream back into something nodejs understands async function* (/** @type {Transform} */source) { for await (const chunk of source) { if (('contents' in chunk) && chunk.contents instanceof Buffer) { yield chunk.contents; } else { throw ERR_CONTENTS_TYPE; } } }, response ); } catch (e) { log_error(e); } } server.on('request', async (request, response) => { const url = new URL(`http://localhost${request.url}`); // serve bundled html as-is, do not minify switch (url.pathname) { case '/': case '/index.htm': case '/index.html': await responseIndex(response); return; } // when module files need browser repl. note the bundling scope, // only this way modules are actually modules and not inlined // in case importmap script was injected into the html const relpath = url.pathname.slice(1); const imported = IMPORT_MAP[/** @type {keyof IMPORT_MAP} */(relpath)]; if (imported) { await responseJsFile(response, imported); return; } // everything else is attempted as html/src/${path} if (url.pathname.endsWith('.mjs')) { await responseJsFile(response, path.join(SRC_DIR, relpath)); return; } response.writeHead(500, {'content-type': 'text/plain'}); response.end('500'); }); server.on('listening', () => { log.info(`Serving ${SRC_DIR} index and *.mjs at`, server.address()); }); server.listen(port, host); } // ----------------------------------------------------------------------------- // Source code validation // ----------------------------------------------------------------------------- /** * map input pattern to paths, do not populate source.contents * @param {string | string[]} pattern */ function sourcePath(pattern) { return [ src(pattern, {read: false, buffer: false}), async function* (/** @type {AsyncIterable} */source) { for await (const chunk of source) { yield chunk.path; } }, ]; } // .spec.mjs vitest tests export async function vitest() { return pipeline( /** @ts-ignore, types/node/stream/promises/pipeline.d.ts hates 'args' / '...args' */ ...sourcePath(TEST_SCRIPTS), async function* (/** @type {AsyncIterable} */source) { // ref. 'vitest/node' parseVitestCLI('vitest --environment jsdom --dir html/spec --run') let filter = []; for await (const chunk of source) { filter.push(chunk); } const { startVitest } = await import('vitest/node'); const runner = await startVitest('test', filter, { config: path.join(ROOT, 'vitest.config.mjs'), run: true, }); await runner.close(); } ); } // Generic javascript linting. *Could* happen at inline stage, but only without compression / minification export async function eslint() { const files = [ ...BUILD_SCRIPTS, ...TEST_SCRIPTS, ...SOURCE_SCRIPTS, ]; return pipeline( /** @ts-ignore, types/node/stream/promises/pipeline.d.ts hates 'args' / '...args' */ ...sourcePath(files), async function* (/** @type {AsyncIterable} */source) { let paths = []; for await (const chunk of source) { paths.push(chunk); } const { ESLint } = await import('eslint'); const runner = new ESLint({}); const format = await runner.loadFormatter('stylish'); const results = await runner.lintFiles(paths); const formatted = await format.format(results); if (formatted.length) { log(formatted); } const fatal = results.some((x) => x.errorCount > 0); if (fatal) { throw new Error(`eslint: ${path} failed`); } }, ); } // Validate all HTML sources. *Cannot* happen at inline stage, since JSDOM modifications break some style rules async function html_validate() { return pipeline( /** @ts-ignore, types/node/stream/promises/pipeline.d.ts hates 'args' / '...args' */ ...sourcePath(SOURCE_HTML), async function* (/** @type {AsyncIterable} */source) { const { FileSystemConfigLoader, HtmlValidate, formatterFactory, } = await import('html-validate'); const html = new HtmlValidate(new FileSystemConfigLoader()); const format = formatterFactory('stylish'); for await (const chunk of source) { const report = await html.validateFile(chunk); if (!report.valid) { log(format(report.results)); throw new Error(`html-validate: ${path} failed`); } } }, ); } export { html_validate as 'html-validate' }; // ----------------------------------------------------------------------------- // Tasks // ----------------------------------------------------------------------------- export async function presets() { /** @type {Error?} */ let rethrow = null; let results = ['No presets generated']; try { results = await buildPresets(); } catch (e) { if (e instanceof Error) { rethrow = e; } else { rethrow = new Error(e?.toString() ?? 'unknown error'); } } finally { for (const result of results) { log(result); } } if (rethrow) { throw rethrow; } } presets.description = 'generate all of the required preset files, based on the html/preset.mjs configuration'; /** @import { ParseArgsOptionsConfig } from 'node:util' */ const ERR_PRESET_EMPTY = new Error('preset flag cannot be empty'); const ERR_PARSE_STRING = new Error('flag type !== string'); const ERR_PARSE_NUMBER = new Error('flag type !== number'); export function build() { const PRESET = 'preset'; /** @type {ParseArgsOptionsConfig} */ const options = { [PRESET]: { type: 'string', multiple: true, default: Array.from(MODULE_BUILD_PRESETS), }, }; // note that this only expects 'build' ... // any extra tasks launched in parallel *may* break // either parsing or the resulting task promise const { values } = parseArgs({ allowPositionals: false, args: process.argv.slice(3), options, }); const { preset } = values; if ((preset == null) || !preset) { throw ERR_PRESET_EMPTY; } /** @param {any} name */ async function run(name) { if (typeof name === 'string') { return buildWebUI(name); } throw ERR_PARSE_STRING; } if (Array.isArray(preset)) { return Promise.all(preset.map(run)); } return run(preset); } build.description = `builds one or more of the available presets: ${Array.from(MODULE_BUILD_PRESETS).join(', ')}`; build.flags = { '--preset NAME': 'NAME of the build preset; can be specified multiple times', }; export function dev() { const PRESET = 'preset'; const HOST = 'host'; const PORT = 'port'; /** @type {ParseArgsOptionsConfig} */ const options = { [PRESET]: { type: 'string', multiple: false, default: MODULE_DEV, }, [HOST]: { type: 'string', multiple: false, default: DEV_HOST, }, [PORT]: { type: 'string', multiple: false, default: DEV_PORT.toString(), }, }; const { values } = parseArgs({ allowPositionals: false, args: process.argv.slice(3), options, }); const { preset, port, host } = values; if (typeof preset !== 'string') { throw ERR_PARSE_STRING; } if (typeof port !== 'string') { throw ERR_PARSE_STRING; } if (typeof host !== 'string') { throw ERR_PARSE_STRING; } const parsed = parseInt(port, 10); if (!parsed) { throw ERR_PARSE_NUMBER; } return serveWebUI({name: preset, port: parsed, host}); } dev.flags = { '--host HOST': `"${DEV_HOST}" by default`, '--port PORT': `${DEV_PORT} by default`, '--preset PRESET': `"${MODULE_DEV}" by default`, }; export const test = parallel( eslint, html_validate, vitest); export default series(test, build);