#!/usr/bin/env node import { createHash } from "node:crypto"; import { readFile, readdir, unlink, writeFile } from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { brotliCompressSync, constants as zlibConstants, gzipSync } from "node:zlib"; import { transform } from "esbuild"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const repoRoot = path.resolve(__dirname, "..", ".."); const JS_MINIFY_TARGET = "es2020"; const templatesDir = path.join(repoRoot, "backend", "bot", "app", "web", "templates"); const JS_BUILDS = [ { sourcePath: path.join(templatesDir, "subscription_webapp.js"), outputPrefix: "subscription_webapp.min", stripDevMock: true, stripFallbackI18nPayload: true, }, { sourcePath: path.join(templatesDir, "subscription_webapp_admin.js"), outputPrefix: "subscription_webapp_admin.min", }, ]; const CSS_BUILDS = [ { sourcePath: path.join(templatesDir, "subscription_webapp.css"), outputPrefix: "subscription_webapp", }, { sourcePath: path.join(templatesDir, "subscription_webapp_admin.css"), outputPrefix: "subscription_webapp_admin", }, ]; function normalizeLineEndings(value) { return value.replace(/\r\n/g, "\n"); } function stripMarkedBlock(source, startMarker, endMarker) { const start = source.indexOf(startMarker); if (start === -1) { return source; } const end = source.indexOf(endMarker, start); if (end === -1) { return source.slice(0, start); } return source.slice(0, start) + source.slice(end + endMarker.length); } function stripFallbackI18n(source) { const fallbackStart = source.indexOf(" const FALLBACK_I18N = {"); const i18nLine = " const I18N = readJsonScript('i18n') || (MOCK && MOCK.i18n) || FALLBACK_I18N;"; const i18nLineIndex = source.indexOf(i18nLine); if (fallbackStart === -1 || i18nLineIndex === -1 || i18nLineIndex < fallbackStart) { return source; } return ( source.slice(0, fallbackStart) + " const I18N = readJsonScript('i18n') || (MOCK && MOCK.i18n) || {};\n" + source.slice(i18nLineIndex + i18nLine.length) ); } async function removeOldHashedAssets(assetDir, pattern, keepNames) { const keep = new Set(Array.isArray(keepNames) ? keepNames : [keepNames]); const entries = await readdir(assetDir, { withFileTypes: true }); await Promise.all( entries .filter((entry) => entry.isFile() && pattern.test(entry.name) && !keep.has(entry.name)) .map((entry) => unlink(path.join(assetDir, entry.name))) ); } async function writePrecompressedAssets(outputPath, body) { const buffer = Buffer.isBuffer(body) ? body : Buffer.from(body, "utf8"); const gzipBody = gzipSync(buffer, { level: 9 }); const brotliBody = brotliCompressSync(buffer, { params: { [zlibConstants.BROTLI_PARAM_QUALITY]: 11, }, }); await Promise.all([ writeFile(`${outputPath}.gz`, gzipBody), writeFile(`${outputPath}.br`, brotliBody), ]); return { gzip: gzipBody.length, brotli: brotliBody.length, }; } async function buildJsAsset({ sourcePath, outputPrefix, stripDevMock = false, stripFallbackI18nPayload = false, }) { const rawSource = await readFile(sourcePath, "utf8"); let strippedSource = normalizeLineEndings(rawSource); if (stripDevMock) { strippedSource = stripMarkedBlock( strippedSource, "/* WEBAPP_DEV_MOCK_START */", "/* WEBAPP_DEV_MOCK_END */" ); } if (stripFallbackI18nPayload) { strippedSource = stripFallbackI18n(strippedSource); } const result = await transform(strippedSource, { charset: "utf8", legalComments: "none", loader: "js", minify: true, target: JS_MINIFY_TARGET, }); const code = `${result.code.replace(/[ \t]+$/gm, "").trimEnd()}\n`; const hash = createHash("sha256").update(code, "utf8").digest("hex").slice(0, 8); const outputPath = path.join(path.dirname(sourcePath), `${outputPrefix}.${hash}.js`); const outputName = path.basename(outputPath); const escapedPrefix = outputPrefix.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); await removeOldHashedAssets( path.dirname(sourcePath), new RegExp(`^${escapedPrefix}\\.[0-9a-f]{8}\\.js(?:\\.(?:br|gz))?$`), [outputName, `${outputName}.br`, `${outputName}.gz`] ); await writeFile(outputPath, code, "utf8"); const compressedJs = await writePrecompressedAssets(outputPath, code); console.log( `Wrote ${path.relative(repoRoot, outputPath)} (${Buffer.byteLength(code, "utf8")} bytes, gzip ${compressedJs.gzip}, br ${compressedJs.brotli})` ); } async function buildCssAsset({ sourcePath, outputPrefix }) { const rawCss = await readFile(sourcePath, "utf8"); const cssResult = await transform(rawCss, { legalComments: "none", loader: "css", minify: true, }); const css = `${cssResult.code.replace(/[ \t]+$/gm, "").trimEnd()}\n`; const cssHash = createHash("sha256").update(css, "utf8").digest("hex").slice(0, 8); const cssOutputPath = path.join(path.dirname(sourcePath), `${outputPrefix}.${cssHash}.css`); const cssOutputName = path.basename(cssOutputPath); const escapedPrefix = outputPrefix.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); await removeOldHashedAssets( path.dirname(sourcePath), new RegExp(`^${escapedPrefix}\\.[0-9a-f]{8}\\.css(?:\\.(?:br|gz))?$`), [cssOutputName, `${cssOutputName}.br`, `${cssOutputName}.gz`] ); await writeFile(cssOutputPath, css, "utf8"); const compressedCss = await writePrecompressedAssets(cssOutputPath, css); console.log( `Wrote ${path.relative(repoRoot, cssOutputPath)} (${Buffer.byteLength(css, "utf8")} bytes, gzip ${compressedCss.gzip}, br ${compressedCss.brotli})` ); } async function main() { for (const build of JS_BUILDS) { await buildJsAsset(build); } for (const build of CSS_BUILDS) { await buildCssAsset(build); } } await main();