Zum Inhalt springen

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

· von

Build-Pipeline mit Node & html-minifier-terser – Symbolbild

~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 (&lt;/&gt;) 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.