PostBuild With ESBuild & Minify-HTML

Add a PostBuild step to your static site CI for image compression and CSS/JS minification. This post covers writing a PostBuild script with ESBuild & minify-html.
  • Build as fast as possible
  • The minifier should understand modern CSS/JS syntax
  • Under those constraints, shrink the output as much as possible
  • Add hash suffixes to CSS/JS filenames to avoid cache versioning issues
The basic workflow of static site deployment

For SSG (Static Site Generation)[1] users, deploying a site usually goes:

Pull source ⇒ Install dependencies ⇒ Generate static files ⇒ Upload & deploy

Take Hexo as an example: running hexo generate locally produces .html, .css, .js, and image assets under the public/ directory. Upload these files directly to a host like EdgeOne Pages or GitHub Pages, and users can then fetch these static assets from edge nodes worldwide.

Alternatively, you can automate the flow with their CI/CD services. Taking EdgeOne (EO for short below) Pages as an example, the flow looks roughly like this:

  • Link your source repository (GitHub / GitLab)
  • Trigger a build when a new commit is detected
  • Automatically run:
    • Pull the code
    • Install dependencies (bun install / npm install)
    • Generate static files (hexo generate / hugo)
  • Deploy the output directory (e.g. public/) to edge nodes

HTML Minification

wilsonzlin/minify-html is a high-performance HTML minifier written in Rust.

Its API is very straightforward. A simple HTML minification script only takes these steps:

  • Use fg() to scan all HTML files under public/
  • Use fs.readFile() to read each HTML file and hand its content to minify-html
  • Use fs.writeFile() to write the minified content back to the original file
build.js
import { minify } from "@minify-html/node";import fg from "fast-glob";import fs from "fs/promises";const ROOT = "public";async function minifyHTML() {  const files = await fg(`${ROOT}/**/*.html`);  await Promise.all(    files.map(async (file) => {      const html = await fs.readFile(file, "utf8");      const minified = minify(Buffer.from(html), {        keep_comments: false,        keep_spaces_between_attributes: false,        minify_css: true, // Minify CSS in `<style>` tags and `style` attributes using https://github.com/parcel-bundler/lightningcss        minify_js: true, // Minify JavaScript in `<script>` tags using minify-js      }).toString();      await fs.writeFile(file, minified);    }),  );  console.log(" HTML minified");}minifyHTML().catch(console.error);

For the options minify accepts, see Cfg in minify_html - Rust.

Whitespace-sensitive contexts

For natively whitespace-sensitive tags like <pre> and <textarea>, minify-html automatically detects them and keeps their inner whitespace intact instead of stripping it.

When rendering flowcharts with mermaid.js, the diagram definitions rely on spaces and indentation for parsing, so make sure mermaid definition code is wrapped in tags like <pre> or <code>.

CSS & JS Minification

ESBuild is fast and supports modern CSS syntax, which already covers my needs for minifying JS and CSS. ESBuild offers a rich API accessible via CLI/JavaScript/GoLang. Let’s start with a simple minify demo.

Say we have an algo/fibonacci.js in the current directory and want to minify it. Here’s how 👇

build.js
import esbuild from "esbuild";await esbuild.build({  entryPoints: ["./algo/fibonacci.js"],  minify: true, // minify the output  outdir: "dist", // write minified files to "dist"; otherwise output goes to stdout by default});
function fibonacci(num) {  let num1 = 0;  let num2 = 1;  let sum;  if (num === 1) {    return num1;  } else if (num === 2) {    return num2;  } else {    for (let i = 3; i <= num; i++) {      sum = num1 + num2;      num1 = num2;      num2 = sum;    }    return num2;  }}console.log("Fibonacci(5): " + fibonacci(5)); // Output: 3console.log("Fibonacci(8): " + fibonacci(8)); // Output: 13
function fibonacci(o){let i=0,e=1,l;if(o===1)return i;if(o===2)return e;for(let n=3;n<=o;n++)l=i+e,i=e,e=l;return e}console.log("Fibonacci(5): "+fibonacci(5)),console.log("Fibonacci(8): "+fibonacci(8));

Embrace Modern CSS🤗

When writing a blog, we want CSS to stay maintainable, so we may reach for modern syntax like Native CSS Nesting[2].

/* modern CSS */.content {  padding: 1rem;  & > h1 {    color: #333;  }}

[[Hexo_Perf_Optmize#Minify HTML/JS/CSS|Previously]] I used the hexo-all_minifier plugin, which relies on clean-css under the hood. That minifier cannot parse nested rules, so CSS styles got lost. Now, esbuild supports newer CSS syntax out of the box.

If you configure target, modern syntax is automatically transpiled down to CSS that older browsers understand. It works fine on my devices, so I’m leaving transpilation off for now

const result = await esbuild.build({  entryPoints: [file],  minify: true,  // target: ['chrome88', 'safari14'],});

Hash Postfix

Why Add Hash Postfix?

On why hash suffixes matter, a fellow forum member already wrote a post about it, see 【互联网老饕小知识】为什么文件要添加一个 hash 后缀?

In short, browsers and CDNs both lean heavily on caching. When you change CSS or JS but the filename stays the same, users will likely keep using the stale cache, resulting in broken styles or misbehaving logic.

From my own experience: a while back I restructured my blog’s footer layout and added the corresponding .footer-grid styles to the CSS.

<html>  <head>    <link rel="stylesheet" href="/css/default.XT6TLN2C.css" />  </head>  <body>    <footer>      <div class="footer-column xxx">yyy</div>      <div class="footer-column xxx">yyy</div>      <div class="footer-column xxx">yyy</div>      <div class="footer-grid">        <div class="footer-column xxx">yyy</div>        <div class="footer-column xxx">yyy</div>        <div class="footer-column xxx">yyy</div>      </div>    </footer>  </body></html>

After deploying, the HTML already contained the .footer-grid level, but the CSS being requested was still the old version, so the page layout broke. Purging the CDN cache or browser cache would fix it too, but adding a content-based hash to asset filenames solves the problem at the root: whenever users load the HTML, they always get references to the matching version of the assets.

<html>  <head>    <link rel="stylesheet" href="/css/default.d4c3b2a1.css" />    <link rel="stylesheet" href="/css/default.a1b2c3d4.css" />  </head></html>

How?

Step 1: Add hash suffixes to output files

ESBuild’s entryNames [3] option controls the output filename format, where the [hash] placeholder is a hash computed from the file content. We can use this to append hash suffixes to output files.

import esbuild from "esbuild";const ROOT = "public";const result = await esbuild.build({  entryPoints: ["./public/js/main.3XGYDUKN.js"],  minify: true,  outbase: ROOT,  outdir: ROOT,  entryNames: "[dir]/[name].[hash]", // append a content-based hash suffix to output files  metafile: true, // generate an input/output mapping});

The minified file lands in public/js/ with a name like main.O3LCB73S.js, where O3LCB73S is the hash computed from the file content.

Once filenames carry hash suffixes, the references to these files in the HTML need to be updated accordingly. Set the metafile parameter to true in the build call, and ESBuild will produce a mapping of inputs to outputs 👇

Thanks to Gemini here, otherwise I’d have spent ages digging through the ESBuild docs

build.js
const manifest = {};// build the rewrite manifest (from original paths to hashed paths)const outputs = result.metafile?.outputs || {};for (const [outPath, meta] of Object.entries(outputs)) {  const entry = meta.entryPoint;  manifest[`/${path.relative(ROOT, entry)}`] =    `/${path.relative(ROOT, outPath)}`;}console.log(manifest);
$ bun build.js{	"/js/main.3XGYDUKN.js": "/js/main.O3LCB73S.js"}

Step 3: Rewrite asset paths in HTML

Tweak the HTML minification function from earlier: with the mapping from Step 2, a string replacement over the HTML is all it takes. The rewriteMap parameter is the manifest variable from Step 2.

build.js
async function minifyHTML(rewriteMap) {  const files = await fg(`${ROOT}/**/*.html`);  await Promise.all(    files.map(async (file) => {      let html = await fs.readFile(file, "utf8");      // rewrite asset paths // [!code ++]      for (const [from, to] of Object.entries(rewriteMap)) {        html = html.replaceAll(from, to);      }      const minified = minify(Buffer.from(html), {        keep_comments: false,        keep_spaces_between_attributes: false,        minify_css: true,        minify_js: true,      }).toString();      await fs.writeFile(file, minified);    }),  );  console.log(" HTML minified (fast)");}

TLDR

Install dependencies

bun add -d esbuild fast-glob @minify-html/node

Copy&Paste

build.js
import { minify } from "@minify-html/node";import esbuild from "esbuild";import fg from "fast-glob";import fs from "fs/promises";import path from "path";const ROOT = "public";async function getJSAndCSSFiles() {  const js_files = await fg(`${ROOT}/js/**/*.js`, {    ignore: ["**/*.min.js"],  });  const css_files = await fg(`${ROOT}/css/**/*.css`, {    ignore: ["**/*.min.css"],  });  return [...js_files, ...css_files];}/* ---------------- JS & CSS ---------------- */async function minifyAssets(files) {  const manifest = {};  const result = await esbuild.build({    entryPoints: files,    minify: true,    bundle: false, // purely static    outdir: ROOT, // output under ROOT    entryNames: "[dir]/[name].[hash]", // use the built-in hash placeholder    metafile: true, // generate an input/output mapping  });  // build the rewrite manifest (from original paths to hashed paths)  const outputs = result.metafile?.outputs || {};  for (const [outPath, meta] of Object.entries(outputs)) {    const entry = meta.entryPoint;    manifest[`/${path.relative(ROOT, entry)}`] =      `/${path.relative(ROOT, outPath)}`;  }  // remove the original files  await Promise.all(files.map((f) => fs.rm(f)));  console.log(` Assets minified`);  return manifest;}/* ---------------- HTML ---------------- */async function minifyHTML(rewriteMap) {  const files = await fg(`${ROOT}/**/*.html`);  await Promise.all(    files.map(async (file) => {      let html = await fs.readFile(file, "utf8");      // rewrite hashed paths      for (const [from, to] of Object.entries(rewriteMap)) {        html = html.replaceAll(from, to);      }      const minified = minify(Buffer.from(html), {        keep_comments: false,        keep_spaces_between_attributes: false,        minify_css: true,        minify_js: true,      }).toString();      await fs.writeFile(file, minified);    }),  );  console.log(" HTML minified (fast)");}/* ---------------- build ---------------- */async function build() {  console.log("⚙️  Building...");  const files = await getJSAndCSSFiles();  if (files.length === 0) {    console.log("No JS or CSS files found to minify.");    await minifyHTML({});    return;  }  const assetsMap = await minifyAssets(files);  await minifyHTML(assetsMap);  console.log("🎉 Done");}build().catch(console.error);

CI Setup

Taking EdgeOne Pages as an example, you can change the build command to hexo gen && bun ./build.js. That said, I’d rather configure scripts in package.json and set the build command to bun run build.

{  "name": "hexo-site",  "version": "0.0.0",  "private": true,  "scripts": {    "build": "hexo gen && bun build.js",    "rebuild": "hexo clean && hexo gen && bun build.js",    "dev": "hexo server",  },  "hexo": {    "version": "8.1.1"  },  "dependencies": {    "hexo": "8.1.1",    ...  },  "devDependencies": {    "@minify-html/node": "^0.18.1",    "esbuild": "^0.27.2",    "fast-glob": "^3.3.3"  }}

Wrapping Up

I started out compressing static assets with the hexo-all_minifier plugin, but its underlying minifier handles modern CSS syntax poorly, and styles went missing.

Switching to ESBuild for JS/CSS minification not only fixed the modern syntax problem but also sped up builds noticeably.

Among the Page hosting services I’ve tried, EdgeOne Pages is easily the slowest, but with the optimizations above the build time improved a lot: average build time dropped from to .


  1. Static Site Generation. Static site generators such as Hexo, Hugo, and Jekyll pre-render Markdown and other content into static HTML files at build time; visitors fetch these static files directly, which usually loads faster. ↩︎

  2. Using CSS nesting - CSS | MDN ↩︎

  3. See https://esbuild.github.io/api/#entry-names ↩︎