﻿---
title: PostBuild with ESBuild & Minify-HTML
date: 2025-12-19
tags:
  - FrontEnd
  - Performance
  - CICD
excerpt: "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."
updated: 2026-07-08 21:21:42
lang: en
i18n:
  cn: /post-build
  translation: 2
---

<script type="module" src="/js/components/tab.js"></script>

- 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

> [!TIPS]- 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 `{shell} 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 (`{shell} bun install` / `{shell} npm install`)
>   - Generate static files (`{shell} hexo generate` / `{shell} hugo`)
> - Deploy the output directory (e.g. `public/`) to edge nodes

## HTML Minification

[wilsonzlin/minify-html](https://github.com/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 `{js} fg()` to scan all HTML files under `public/`
- Use `{js} fs.readFile()` to read each HTML file and hand its content to `minify-html`
- Use `{js} fs.writeFile()` to write the minified content back to the original file

```js build.js
import { minify } from "@minify-html/node"; // [!code highlight]
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), {
        // [!code highlight]
        keep_comments: false, // [!code highlight]
        keep_spaces_between_attributes: false, // [!code highlight]
        minify_css: true, // [!code highlight] Minify CSS in `<style>` tags and `style` attributes using https://github.com/parcel-bundler/lightningcss
        minify_js: true, // [!code highlight] Minify JavaScript in `<script>` tags using minify-js
      }).toString(); // [!code highlight]

      await fs.writeFile(file, minified);
    }),
  );
  console.log("✓ HTML minified");
}

minifyHTML().catch(console.error);
```

For the options `minify` accepts, see [Cfg in minify_html - Rust](https://docs.rs/minify-html/latest/minify_html/struct.Cfg.html).

> [!WARNING]- Whitespace-sensitive contexts
>
> For natively whitespace-sensitive tags like `{html} <pre>` and `{html} <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 `{html} <pre>` or `{html} <code>`.

## CSS & JS Minification

[ESBuild](https://esbuild.github.io/) 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 👇

<x-tabs>

<x-tab title="Example" active>

```js build.js
import esbuild from "esbuild";

await esbuild.build({
  entryPoints: ["./algo/fibonacci.js"],
  minify: true, // [!code ++] minify the output
  outdir: "dist", // [!code ++] write minified files to "dist"; otherwise output goes to stdout by default
});
```

</x-tab>

<x-tab title="Original file">

```js
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: 3
console.log("Fibonacci(8): " + fibonacci(8)); // Output: 13
```

</x-tab>

<x-tab title="Minified result">

```shell
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));
```

</x-tab>

</x-tabs>

### 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].

```css
/* 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~~

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

## Hash Postfix

> [!question]- Why Add Hash Postfix?
>
> On why hash suffixes matter, a fellow forum member already wrote a post about it, see [【互联网老饕小知识】为什么文件要添加一个 hash 后缀？](https://linux.do/t/topic/1261014)
>
> 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
> <html>
>   <head>
>     <link rel="stylesheet" href="/css/default.css" />
>     // [!code warning]
>   </head>
>   <body>
>     <footer>
>       <div class="footer-column xxx">yyy</div>
>       // [!code --]
>       <div class="footer-column xxx">yyy</div>
>       // [!code --]
>       <div class="footer-column xxx">yyy</div>
>       // [!code --]
>       <div class="footer-grid">
>         // [!code ++]
>         <div class="footer-column xxx">yyy</div>
>         // [!code ++]
>         <div class="footer-column xxx">yyy</div>
>         // [!code ++]
>         <div class="footer-column xxx">yyy</div>
>         // [!code ++]
>       </div>
>       // [!code ++]
>     </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
> <html>
>   <head>
>     <link rel="stylesheet" href="/css/default.d4c3b2a1.css" />
>     // [!code --]
>     <link rel="stylesheet" href="/css/default.a1b2c3d4.css" />
>     // [!code ++]
>   </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.

```js
import esbuild from "esbuild";

const ROOT = "public";

const result = await esbuild.build({
  entryPoints: ["./public/js/main.js"],
  minify: true,
  outbase: ROOT,
  outdir: ROOT,
  entryNames: "[dir]/[name].[hash]", // [!code ++] append a content-based hash suffix to output files
  metafile: true, // [!code ++] 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~~

<x-tabs>

<x-tab title="Code" active>

```js build.js
// [!code word:result]
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);
```

</x-tab>

<x-tab title="Output">

```shell
$ bun build.js
{
	"/js/main.js": "/js/main.O3LCB73S.js"
}
```

</x-tab>

</x-tabs>

#### 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.

```js build.js
async function minifyHTML(rewriteMap) {
  // [!code warning]
  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)) {
        // [!code ++]
        html = html.replaceAll(from, to); // [!code ++]
      } // [!code ++]
      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

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

### Copy&Paste

```js 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 `{shell} hexo gen && bun ./build.js`. That said, I'd rather configure `scripts` in `package.json` and set the build command to `{shell} bun run build`.

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

```

## 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 $100s$ to $50s$.

[^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](https://developer.mozilla.org/en-US/docs/Web/CSS/Guides/Nesting/Using)

[^3]: See https://esbuild.github.io/api/#entry-names
