import { mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
import { spawn } from "node:child_process";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { minify as minifyHtml } from "html-minifier-terser";
import { transform as transformCss } from "lightningcss";
import postcss from "postcss";
import postcssNested from "postcss-nested";
import { minify as minifyJs } from "terser";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const rootDir = path.resolve(__dirname, "..");
const commonUiSrcDir = "arma/client/addons/common/ui/src";
const commonUiSiteDir = "arma/client/addons/common/ui/_site";
const clientAddonsDir = path.join(rootDir, "arma/client/addons");
function toRepoRelative(absolutePath) {
return path.relative(rootDir, absolutePath).replace(/\\/g, "/");
}
function resolveFromRoot(...segments) {
return toRepoRelative(path.join(rootDir, ...segments));
}
function resolveFromConfigDir(configDir, relativePath) {
return toRepoRelative(path.resolve(configDir, relativePath));
}
const commonJsBundles = [
{
name: "Forge Web UI runtime",
output: resolveFromRoot(commonUiSiteDir, "forge-webui.js"),
sources: [
"runtime.js",
"host.js",
"bridge.js",
"app.js",
"windowTitleBar.js",
"index.js",
].map((relativePath) => resolveFromRoot(commonUiSrcDir, relativePath)),
},
{
name: "Forge Web UI site loader",
output: resolveFromRoot(commonUiSiteDir, "forge-site-loader.js"),
sources: [resolveFromRoot(commonUiSrcDir, "siteLoader.js")],
},
];
const commonFormatSourceTargets = [resolveFromRoot(commonUiSrcDir)];
function unique(values) {
return Array.from(new Set(values));
}
async function readSource(relativePath) {
const absolutePath = path.join(rootDir, relativePath);
return readFile(absolutePath, "utf8");
}
async function writeBundle(outputRelativePath, content) {
const outputPath = path.join(rootDir, outputRelativePath);
await mkdir(path.dirname(outputPath), { recursive: true });
await writeFile(outputPath, content, "utf8");
}
async function cleanOutputDirs(outputDirs) {
const uniqueDirs = unique(outputDirs).filter(Boolean);
await Promise.all(
uniqueDirs.map(async (relativeDir) => {
const absoluteDir = path.join(rootDir, relativeDir);
await rm(absoluteDir, { force: true, recursive: true });
await mkdir(absoluteDir, { recursive: true });
}),
);
}
async function buildJsBundle({ name, output, sources }) {
const chunks = await Promise.all(sources.map(readSource));
const bundleSource = chunks.join("\n\n");
const result = await minifyJs(bundleSource, {
compress: true,
mangle: true,
format: {
comments: false,
},
});
if (!result?.code) {
throw new Error(`Failed to minify JavaScript bundle for ${name}.`);
}
await writeBundle(output, result.code);
console.log(`Built ${output}`);
}
async function buildCssBundle({ name, output, sources }) {
const chunks = await Promise.all(sources.map(readSource));
const nestedResult = await postcss([postcssNested]).process(
chunks.join("\n\n"),
{
from: undefined,
},
);
const result = transformCss({
filename: output,
code: Buffer.from(nestedResult.css),
minify: true,
});
await writeBundle(output, result.code.toString("utf8"));
console.log(`Built ${output}`);
}
function renderSiteIndex({ title, siteConfig }) {
const configJson = JSON.stringify(siteConfig, null, 16)
.replace(/^/gm, " ".repeat(12))
.trimStart();
return `
${title}
`;
}
async function buildHtmlPage({ name, output, title, siteConfig }) {
const html = renderSiteIndex({ title, siteConfig });
const minifiedHtml = await minifyHtml(html, {
collapseBooleanAttributes: true,
collapseWhitespace: true,
minifyCSS: true,
minifyJS: true,
removeComments: true,
removeRedundantAttributes: true,
});
await writeBundle(output, minifiedHtml);
console.log(`Built ${output}`);
}
async function pathExists(absolutePath) {
try {
await stat(absolutePath);
return true;
} catch {
return false;
}
}
async function runPrettier(targets) {
const uniqueTargets = unique(targets).filter(Boolean);
if (uniqueTargets.length === 0) {
return;
}
console.log(`Formatting ${uniqueTargets.length} Web UI target(s) with Prettier`);
await new Promise((resolve, reject) => {
const quotedTargets = uniqueTargets.map((target) =>
`"${String(target).replace(/"/g, '\\"')}"`,
);
const command = `npx prettier --write --ignore-unknown ${quotedTargets.join(" ")}`;
const child = spawn(command, [], {
cwd: rootDir,
stdio: "inherit",
shell: true,
});
child.on("error", reject);
child.on("exit", (code) => {
if (code === 0) {
resolve();
return;
}
reject(
new Error(`Prettier failed with exit code ${code ?? "unknown"}.`),
);
});
});
}
async function discoverUiConfigs() {
const addons = await readdir(clientAddonsDir, { withFileTypes: true });
const configPaths = [];
for (const entry of addons) {
if (!entry.isDirectory()) {
continue;
}
const configPath = path.join(
clientAddonsDir,
entry.name,
"ui",
"ui.config.mjs",
);
try {
const configStat = await stat(configPath);
if (configStat.isFile()) {
configPaths.push(configPath);
}
} catch {
// UI config is optional per addon.
}
}
configPaths.sort((left, right) => left.localeCompare(right));
return configPaths;
}
async function loadUiConfig(absoluteConfigPath) {
const configModule = await import(pathToFileURL(absoluteConfigPath).href);
const config = configModule.default;
if (!config || !config.addonName || !config.outputDir || !config.site) {
throw new Error(
`Invalid UI config at ${toRepoRelative(absoluteConfigPath)}.`,
);
}
const configDir = path.dirname(absoluteConfigPath);
const configRelativePath = toRepoRelative(absoluteConfigPath);
const outputDir = resolveFromConfigDir(configDir, config.outputDir);
const srcDirPath = path.join(configDir, "src");
const formatSourceTargets = [configRelativePath];
if (await pathExists(srcDirPath)) {
formatSourceTargets.push(toRepoRelative(srcDirPath));
}
const jsBundles = (config.jsBundles || []).map((bundle) => ({
name: bundle.name,
output: resolveFromConfigDir(configDir, path.join(config.outputDir, bundle.output)),
sources: (bundle.sources || []).map((source) =>
resolveFromConfigDir(configDir, source),
),
}));
const cssBundles = (config.cssBundles || []).map((bundle) => ({
name: bundle.name,
output: resolveFromConfigDir(configDir, path.join(config.outputDir, bundle.output)),
sources: (bundle.sources || []).map((source) =>
resolveFromConfigDir(configDir, source),
),
}));
const htmlPage = {
name: `${config.addonName} UI index`,
output: resolveFromConfigDir(configDir, path.join(config.outputDir, "index.html")),
title: config.title,
siteConfig: {
addonName: config.addonName,
logLabel: config.logLabel || `${config.addonName} UI`,
...config.site,
},
};
return {
outputDir,
jsBundles,
cssBundles,
htmlPage,
formatSourceTargets,
};
}
async function collectUiBuildArtifacts() {
const configPaths = await discoverUiConfigs();
const uiConfigs = await Promise.all(configPaths.map(loadUiConfig));
return {
outputDirs: uiConfigs.map((config) => config.outputDir),
jsBundles: uiConfigs.flatMap((config) => config.jsBundles),
cssBundles: uiConfigs.flatMap((config) => config.cssBundles),
htmlPages: uiConfigs.map((config) => config.htmlPage),
formatSourceTargets: uiConfigs.flatMap(
(config) => config.formatSourceTargets,
),
};
}
async function build() {
const uiArtifacts = await collectUiBuildArtifacts();
const commonOutputDirs = [resolveFromRoot(commonUiSiteDir)];
await runPrettier([
...commonFormatSourceTargets,
...uiArtifacts.formatSourceTargets,
]);
await cleanOutputDirs([...commonOutputDirs, ...uiArtifacts.outputDirs]);
await Promise.all([
...commonJsBundles.map(buildJsBundle),
...uiArtifacts.jsBundles.map(buildJsBundle),
]);
await Promise.all(uiArtifacts.cssBundles.map(buildCssBundle));
await Promise.all(uiArtifacts.htmlPages.map(buildHtmlPage));
}
build().catch((error) => {
console.error("Failed to build Forge Web UI bundles.");
console.error(error);
process.exitCode = 1;
});