"use strict"; const gulp = require("gulp"); const sass = require("gulp-sass")(require("sass")); const less = require("gulp-less"); const concat = require("gulp-concat"); const cleanCSS = require("gulp-clean-css"); const htmlmin = require("gulp-htmlmin"); const terser = require("gulp-terser"); const gulpif = require("gulp-if"); const merge2 = require("merge2"); const copyconfig = require("./copyconfig.json"); const path = require("path"); const fs = require("fs"); const { Transform, Writable } = require("stream"); // Node pipeline (promise-based) let pipeline; try { ({ pipeline } = require("stream/promises")); } catch { pipeline = require("stream").promises.pipeline; } // -------------------------- // Config loaders // -------------------------- function loadBundleConfig() { delete require.cache[require.resolve("./bdlconfig.json")]; return require("./bdlconfig.json"); } const terserOptions = { parse: { ecma: 2020 }, compress: { ecma: 2020 }, mangle: true, output: { ecma: 2020 }, }; const regex = { css: /\.css$/i, html: /\.(html|htm)$/i, js: /\.js$/i, }; // -------------------------- // Helpers // -------------------------- function safeUnlink(filePath) { try { if (fs.existsSync(filePath)) { fs.unlinkSync(filePath); console.log("Deleted:", filePath); } } catch (err) { console.warn("Could not delete file:", filePath, "-", err.message); } } function handleTerserError(err) { console.error("========================================"); console.error("TERSER ERROR:", err && err.message ? err.message : err); if (err && (err.filename || err.fileName)) console.error("File:", err.filename || err.fileName); if (err && (err.line != null || err.col != null)) console.error("Line:", err.line, "Col:", err.col); console.error(String(err)); console.error("========================================"); this.emit("end"); } /** * Safe, non-hanging replacement for gulp-preservetime. * Use AFTER gulp.dest(). */ function preservetimeSafe() { return new Transform({ objectMode: true, transform(file, _enc, cb) { try { if (!file || !file.path || !file.stat) return cb(null, file); if (typeof file.isDirectory === "function" && file.isDirectory()) return cb(null, file); const atime = file.stat.atime instanceof Date ? file.stat.atime : new Date(file.stat.atimeMs ?? Date.now()); const mtime = file.stat.mtime instanceof Date ? file.stat.mtime : new Date(file.stat.mtimeMs ?? Date.now()); fs.utimes(file.path, atime, mtime, () => cb(null, file)); } catch (_e) { cb(null, file); } }, }); } function logSink(prefix) { return new Writable({ objectMode: true, write(file, _enc, cb) { try { if (file && file.path) { const rel = path.relative(process.cwd(), file.path); console.log(prefix + rel); } } catch (_e) { } cb(); }, }); } function getBundles(regexPattern) { const bundleconfig = loadBundleConfig(); return bundleconfig.filter((bundle) => regexPattern.test(bundle.outputFileName)); } function normalizeRel(p) { return String(p || "").replace(/\//g, "|").replace(/\\/g, "|"); } // Normalize slashes for Windows friendliness (outputFileName uses forward slashes) function normalizePath(p) { return String(p || "").replace(/\//g, path.sep); } // Make watch list more complete for SCSS: include likely include roots. // (Safe: only affects watch mode, not build output.) function inferScssWatchGlobs(bundle) { const inputs = (bundle.inputFiles || []).slice(); const scss = inputs.filter((f) => /\.s[ac]ss$/i.test(f)); if (scss.length === 0) return []; const dirSet = new Set(); for (const f of scss) { const d = path.dirname(normalizePath(f)); if (d && d !== ".") dirSet.add(d); } // Watch all scss/sass partials and imports under those folders. // This catches changes to partials that aren't explicitly listed in bdlconfig.json. return Array.from(dirSet).map((d) => path.join(d, "**", "*.s[ac]ss")); } // -------------------------- // COPY (separate task) // -------------------------- gulp.task("copy", async () => { const jobs = []; for (const cpy of copyconfig) { jobs.push( pipeline(gulp.src(cpy.src, { allowEmpty: true }), gulp.dest(cpy.dest), preservetimeSafe()) ); } await Promise.all(jobs); }); // -------------------------- // JS // -------------------------- gulp.task("min:js", async () => { for (const bundle of getBundles(regex.js)) { const minify = typeof (bundle.minify || {}).enabled === "boolean" ? bundle.minify.enabled : true; const inputs = (bundle.inputFiles || []).slice(); const toMinifyFiles = (bundle.inputFiles_tominify || []).slice(); const outNorm = normalizePath(bundle.outputFileName); const outputDir = path.dirname(outNorm); const outputFile = path.basename(outNorm); console.log("Bundle:", bundle.outputFileName, "| Minify:", minify, "| Inputs:", inputs.length + toMinifyFiles.length); if (minify === true) { const allInputs = inputs.concat(toMinifyFiles); const nonMinFile = outputFile.includes(".min.") ? outputFile.replace(".min.", ".") : outputFile; const minFile = outputFile.includes(".min.") ? outputFile : outputFile.replace(/\.js$/i, ".min.js"); safeUnlink(path.join(outputDir, nonMinFile)); safeUnlink(path.join(outputDir, minFile)); await pipeline( gulp.src(allInputs, { base: ".", allowEmpty: false }), concat(nonMinFile), gulp.dest(outputDir), logSink("Non-Minified: ") ); await pipeline( gulp.src(allInputs, { base: ".", allowEmpty: false }), concat(minFile), terser(terserOptions).on("error", handleTerserError), gulp.dest(outputDir), logSink("Minified: ") ); } else { safeUnlink(path.join(outputDir, outputFile)); // Build source streams separately to avoid anymatch index confusion // when negated globs and singular paths are mixed in one gulp.src call const srcStreams = []; if (inputs.length > 0) { srcStreams.push(gulp.src(inputs, { base: ".", allowEmpty: false })); } if (toMinifyFiles.length > 0) { srcStreams.push( gulp.src(toMinifyFiles, { base: ".", allowEmpty: false }) .pipe(terser(terserOptions).on("error", handleTerserError)) ); } const merged = srcStreams.length === 1 ? srcStreams[0] : merge2(srcStreams); await pipeline( merged, concat(outputFile), gulp.dest(outputDir), logSink("Bundled: ") ); } } }); // -------------------------- // CSS source builder // IMPORTANT: the intermediate concat names MUST NOT start with "_" (gulp-sass ignores those) // -------------------------- function getCssSourceAndSteps(bundle) { const inputs = (bundle.inputFiles || []).slice(); const scssFiles = inputs.filter((f) => /\.s[ac]ss$/i.test(f)); const lessFiles = inputs.filter((f) => /\.less$/i.test(f)); const cssFiles = inputs.filter((f) => /\.css$/i.test(f) && !/\.s[ac]ss$/i.test(f)); const srcOpts = { base: ".", allowEmpty: false }; // Make Sass FAIL the build (no sass.logError swallowing) if (scssFiles.length && !lessFiles.length && !cssFiles.length) { return { src: gulp.src(scssFiles, srcOpts), steps: [concat("bundle.scss"), sass()], }; } if (lessFiles.length && !scssFiles.length && !cssFiles.length) { return { src: gulp.src(lessFiles, srcOpts), steps: [concat("bundle.less"), less()], }; } if (cssFiles.length && !scssFiles.length && !lessFiles.length) { return { src: gulp.src(cssFiles, srcOpts), steps: [], }; } throw new Error(`Mixed CSS types in bundle ${bundle.outputFileName}. Split into separate bundles.`); } // -------------------------- // CSS (min:css + min:scss alias) // -------------------------- async function minCssImpl() { for (const bundle of getBundles(regex.css)) { const minify = typeof (bundle.minify || {}).enabled === "boolean" ? bundle.minify.enabled : true; const outNorm = normalizePath(bundle.outputFileName); const outputDir = path.dirname(outNorm); const outputBase = path.basename(outNorm); console.log( "Bundle:", bundle.outputFileName, "| Minify:", minify, "| Inputs:", (bundle.inputFiles || []).length ); if (minify === true) { const nonMinFile = outputBase.includes(".min.") ? outputBase.replace(".min.", ".") : outputBase; const minFile = outputBase.includes(".min.") ? outputBase : outputBase.replace(/\.css$/i, ".min.css"); safeUnlink(path.join(outputDir, nonMinFile)); safeUnlink(path.join(outputDir, minFile)); // Non-minified { const spec = getCssSourceAndSteps(bundle); await pipeline( spec.src, ...spec.steps, concat(nonMinFile), gulp.dest(outputDir), logSink("Non-Minified: ") ); } // Minified (fresh stream) { const spec = getCssSourceAndSteps(bundle); await pipeline( spec.src, ...spec.steps, concat(minFile), // cleanCSS level 2 + single-line stats cleanCSS({ level: 2, debug: true }, (details) => { const name = details.name || path.basename(minFile); console.log(`${name}: ${details.stats.originalSize} -> ${details.stats.minifiedSize}`); }), gulp.dest(outputDir), logSink("Minified: ") ); } } else { safeUnlink(path.join(outputDir, outputBase)); const spec = getCssSourceAndSteps(bundle); await pipeline( spec.src, ...spec.steps, concat(outputBase), gulp.dest(outputDir), logSink("Bundled: ") ); } } } gulp.task("min:css", minCssImpl); gulp.task("min:scss", minCssImpl); // -------------------------- // HTML // -------------------------- gulp.task("min:html", async () => { for (const bundle of getBundles(regex.html)) { const minify = typeof (bundle.minify || {}).enabled === "boolean" ? bundle.minify.enabled : true; safeUnlink(bundle.outputFileName); await pipeline( gulp.src(bundle.inputFiles || [], { base: ".", allowEmpty: false }), concat(bundle.outputFileName), htmlmin({ collapseWhitespace: true, minifyCSS: minify, minifyJS: minify }), gulp.dest("."), logSink("Bundled: ") ); } }); // -------------------------- // Aggregate / Watch // -------------------------- gulp.task("min", gulp.parallel("min:js", "min:css", "min:html")); gulp.task("all", gulp.series("copy", "min")); gulp.task("watch", function () { getBundles(regex.js).forEach((bundle) => { const watchFiles = [] .concat(bundle.inputFiles || []) .concat(bundle.inputFiles_tominify || []); gulp.watch(watchFiles, gulp.series("min:js")); }); getBundles(regex.css).forEach((bundle) => { const watchFiles = [].concat(bundle.inputFiles || []); const extraScssGlobs = inferScssWatchGlobs(bundle); gulp.watch(watchFiles.concat(extraScssGlobs), gulp.series("min:css")); }); getBundles(regex.html).forEach((bundle) => { gulp.watch(bundle.inputFiles || [], gulp.series("min:html")); }); });