Build-Pipeline mit Node & html-minifier-terser – meine build.mjs erklärt (2025)

~12 Min. Lesezeit · Veröffentlicht am
Wie baue ich eigentlich meine Seite? In diesem Artikel zeige ich die komplette Build-Pipeline – von Partials und -Ersetzung bis hin zu Minifizierung, Bild-Pipeline, Sitemap und Auto-Deploy. Meine build.mjs ist bewusst minimalistisch, aber deckt alle wichtigen Schritte für eine moderne statische Seite ab.
Voraussetzungen & Installation
Ich nutze eine schlanke Node-Toolchain. Hier ist mein Setup von Null auf – inkl. Abhängigkeiten, die in der build.mjs verwendet werden.
Node & Projektinitialisierung
# Node-Version prüfen (22.x empfohlen)
node -v
# Neues Projekt initialisieren (oder bestehendes)
npm init -y
Benötigte Module installieren
# Build & Utilities
npm install --save-dev esbuild html-minifier-terser tailwindcss
# FS/Path Utilities (als Runtime-Dependency, da build.mjs sie importiert)
npm install fs-extra
# (Optional) Bild-Processing in separatem Script
# npm install sharp imagemin imagemin-webp imagemin-avif Projektstruktur (vereinfachtes Beispiel)
dcm-net-2025-v2/
├─ src/
│ ├─ blog/
│ ├─ css/ (Tailwind input.css)
│ ├─ img/ (globale Bilder)
│ └─ partials/ (nav.html, footer.html, blog_related_post.html)
├─ public/ (statische Assets)
├─ scripts/
│ ├─ build.mjs
│ ├─ backup.mjs
│ └─ images.mjs (optional)
├─ dist/ (Build-Ziel)
├─ package.json
└─ tailwind.config.js package.json Scripts
{
"scripts": {
"dev": "node scripts/build.mjs dev --base=/dcm-net-2025-v2",
"build": "node scripts/build.mjs --base=/dcm-net-2025-v2",
"build:blog": "node scripts/build.mjs --blog --base=/dcm-net-2025-v2",
"tailwind:watch": "tailwindcss -i ./src/css/input.css -o ./dist/assets/css/style.css --watch"
"backup": "node scripts/backup.mjs"
}
} Partials &
Mit <!-- @include … --> binde ich wiederverwendbare Snippets wie Navigation und Footer ein. Zusätzlich ersetze ich den Platzhalter , damit ich die Seite auch in einem Unterordner deployen kann. Der Kern ist eine kleine Include-Engine:
// Ausschnitt aus build.mjs
import fs from 'fs-extra';
import path from 'path';
async function resolvePartials(html, rootDir) {
const includeRe = /<!--\\s*@include\\s+([^\\s]+)\\s*-->/gi;
async function replaceAsync(str, regex) {
const matches = [];
str.replace(regex, (...args) => { matches.push(args); return ''; });
for (const match of matches) {
const [full, relPath] = match;
const partPath = relPath.startsWith('/')
? path.join('src', relPath.replace(/^\\//, ''))
: path.join(rootDir, relPath);
let part = '';
try {
part = await fs.readFile(partPath, 'utf8');
part = await resolvePartials(part, path.dirname(partPath));
} catch (e) {
console.warn('Include nicht gefunden:', relPath);
}
str = str.replace(full, part);
}
return str;
}
return replaceAsync(html, includeRe);
}
async function buildHtmlWithPartials(srcPath, destPath, BASE_PATH = '') {
let html = await fs.readFile(srcPath, 'utf8');
html = await resolvePartials(html, path.dirname(srcPath));
if (html.includes('')) html = html.replace(//g, BASE_PATH);
// Link-Normalisierung
html = html.replace(/href=(["'])([^"']*?)\\/index\\.html#/gi, 'href=$1$2/#')
.replace(/href=(["'])([^"']*?)\\/index\\.html(["'#])/gi, 'href=$1$2/$3');
await fs.ensureDir(path.dirname(destPath));
await fs.writeFile(destPath, html, 'utf8');
} Blog-Only Modus
Beim Schreiben neuer Artikel nutze ich --blog, um nur die Blog-Seiten zu bauen – das spart Zeit. Im Build-Script schalte ich damit große Schritte (Tailwind, bundling, Images) optional ab:
// Flags lesen
const isDev = process.argv.includes('dev');
const blogOnly = process.argv.includes('blog') || process.argv.includes('--blog');
const baseArg = process.argv.find(a => a.startsWith('--base='));
const BASE_PATH = (process.env.BASE_PATH || (baseArg ? baseArg.split('=')[1] : '') || '').replace(/\\/$/, '');
// Blog-Build: nur src/blog -> dist/blog
if (await fs.pathExists('src/blog')) {
const entries = await fs.readdir('src/blog');
for (const name of entries) {
const src = path.join('src/blog', name);
const dst = path.join('dist/blog', name);
const stat = await fs.stat(src);
if (stat.isDirectory()) {
await fs.copy(src, dst, { overwrite: true });
} else if (name.toLowerCase().endsWith('.html')) {
await buildHtmlWithPartials(src, dst, BASE_PATH);
} else {
await fs.copy(src, dst, { overwrite: true });
}
}
}
// Teure Schritte nur im Full Build
if (!blogOnly) {
// Tailwind, esbuild, Images …
} Minify ohne kaputtes HTML
Der heikle Teil: Minifizieren. Ich sichere JSON-LD-Blöcke, deaktiviere minifyJS (um Inline-Skripte nicht zu brechen) und nutze ignoreCustomFragments für PHP- und Code-Blöcke. Wichtig: Vergleichszeichen in Texten immer als Entities (</>) schreiben.
import { minify as minifyHtml } from 'html-minifier-terser';
function extractJsonLd(html) {
const entries = [];
const re = /(<script[^>]*type=["']application\\/ld\\+json["'][^>]*>)([\\s\\S]*?)(<\\/script>)/gi;
let i = 0;
html = html.replace(re, (_, open, json, close) => {
let minJson = json;
try { minJson = JSON.stringify(JSON.parse(json)); } catch {}
entries.push({ open, json: minJson, close });
return `%%JSONLD_${i++}%%`;
});
return { html, entries };
}
function restoreJsonLd(html, entries) {
entries.forEach((e, idx) => { html = html.replace(`%%JSONLD_${idx}%%`, `${e.open}${e.json}${e.close}`); });
return html;
}
const { html: protectedHtml, entries } = extractJsonLd(html);
const minified = await minifyHtml(protectedHtml, {
collapseWhitespace: true,
removeComments: true,
removeRedundantAttributes: true,
removeScriptTypeAttributes: true,
removeStyleLinkTypeAttributes: true,
keepClosingSlash: true,
useShortDoctype: true,
sortAttributes: true,
sortClassName: true,
minifyCSS: true,
minifyJS: false,
ignoreCustomFragments: [
/<\\?php[\\s\\S]*?\\?>/g,
/<pre><code[\\s\\S]*?<\\/code><\\/pre>/g
]
});
html = restoreJsonLd(minified, entries); Bild-Pipeline
Über data-auto-picture ersetze ich IMG-Tags nach dem Build durch ein <picture> mit AVIF/WebP-Quellen. So bleiben Templates simpel, aber die Auslieferung ist modern.
function toPictureTag(imgTag) {
const srcMatch = imgTag.match(/\\ssrc=["']([^"']+)["']/i);
if (!srcMatch) return imgTag;
const src = srcMatch[1];
const sizesAttr = imgTag.match(/\\sdata-sizes=["']([^"']+)["']/i);
const sizes = sizesAttr ? sizesAttr[1] : '(min-width:1024px) 50vw, 90vw';
const dir = path.posix.dirname(src);
const file = path.posix.basename(src);
const m = file.match(/^(.+?)(?:-(\\d+))?\\.(avif|webp|jpe?g|png)$/i);
if (!m) return imgTag;
const base = m[1];
const avif = [
`${dir}/${base}-600.avif 600w`,
`${dir}/${base}-900.avif 900w`,
`${dir}/${base}-1200.avif 1200w`,
].join(', ');
const webp = [
`${dir}/${base}-600.webp 600w`,
`${dir}/${base}-900.webp 900w`,
`${dir}/${base}-1200.webp 1200w`,
].join(', ');
let cleaned = imgTag.replace(/\\sdata-auto-picture\\b/ig, '').replace(/\\sdata-sizes=["'][^"']*["']/ig, '');
cleaned = cleaned.replace(/\\ssrc=["'][^"']*["']/i, ` src="${dir}/${base}-900.webp"`);
return `<picture>
<source type="image/avif" srcset="${avif}" sizes="${sizes}">
<source type="image/webp" srcset="${webp}" sizes="${sizes}">
${cleaned}
</picture>`;
} Sitemap-Generator
Die Sitemap entsteht aus allen HTML-Dateien in dist/. Prioritäten und changefreq vergebe ich heuristisch, lastmod kommt vom Dateisystem:
async function listHtmlFiles(dir) {
const out = [];
if (!(await fs.pathExists(dir))) return out;
const items = await fs.readdir(dir, { withFileTypes: true });
for (const it of items) {
const p = path.join(dir, it.name);
if (it.isDirectory()) out.push(...await listHtmlFiles(p));
else if (it.isFile() && p.toLowerCase().endsWith('.html')) out.push(p);
}
return out;
}
function classify(url) {
const u = url.toLowerCase();
if (u.endsWith('/')) return { changefreq: 'monthly', priority: 1.0 };
if (u.includes('/blog/')) return { changefreq: 'monthly', priority: 0.8 };
if (u.endsWith('/impressum.html') || u.endsWith('/datenschutz.html')) return { changefreq: 'yearly', priority: 0.5 };
if (u.endsWith('/404.html')) return { changefreq: 'yearly', priority: 0.1 };
return { changefreq: 'monthly', priority: 0.6 };
} Deploy nach iCloud
Mein Setup kopiert den fertigen dist/-Ordner direkt in meinen iCloud-Sites-Ordner – ideal zum schnellen Testen auf mehreren Macs. Der Pfad wird aus dem Projektnamen abgeleitet:
const packageJson = await fs.readJson('package.json');
const projectName = packageJson.name;
const targetDir = `/Users/smu/Library/Mobile Documents/com~apple~CloudDocs/Work/Sites/${projectName}`;
await fs.copy('dist', targetDir, { overwrite: true }); Alternative per rsync (Server-Deploy):
rsync -avz --delete dist/ user@server:/var/www/html/ „Der eigene Build-Prozess ist wie ein Werkzeugkasten: Er sollte genau das enthalten, was man wirklich braucht – nicht mehr, nicht weniger.“
– Sven Muscheid
👉 Der vollständige Build-Code liegt öffentlich auf GitHub: smu-essen/node-tailwind-build-starter
📦 Direkt-Download als ZIP: Zum aktuellen Release (ZIP-Download)
Hast du Fragen zu meinem Build-Prozess oder willst du ihn nachbauen? Meld dich gerne bei mir.