Files
remnawave-minishop/frontend/scripts/build_subscription_webapp_js.mjs

177 lines
5.8 KiB
JavaScript

#!/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();