PostBuild 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 underpublic/ - Use
fs.readFile()to read each HTML file and hand its content tominify-html - Use
fs.writeFile()to write the minified content back to the original file
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 For natively whitespace-sensitive tags like When rendering flowcharts with minify accepts, see Cfg in minify_html - Rust.
<pre> and <textarea>, minify-html automatically detects them and keeps their inner whitespace intact instead of stripping it.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 👇
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: 13function 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 nowconst 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
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.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/nodeCopy&Paste
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 .
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. ↩︎