mirror of
https://github.com/xoseperez/espurna.git
synced 2026-02-20 01:31:34 +01:00
1286 lines
36 KiB
JavaScript
1286 lines
36 KiB
JavaScript
/*
|
|
|
|
ESP8266 file system builder
|
|
|
|
Copyright (C) 2016-2019 by Xose Pérez <xose dot perez at gmail dot com>
|
|
Copyright (C) 2019-2024 by Maxim Prokhorov <prokhorov dot max at outlook dot com>
|
|
|
|
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 <http://www.gnu.org/licenses/>.
|
|
|
|
*/
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// 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<boolean>)} 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, <Buffer>, <TypedArray> and <DataView> 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 <sys/pgmspace.h>',
|
|
'#include <cstdint>',
|
|
`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<ArrayBufferLike>} 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<http.IncomingMessage>} 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<http.IncomingMessage>} 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<File>} */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<any>} */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<string>} */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<string>} */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);
|