#!/usr/bin/env node // Post-build class minifier - updates BOTH HTML classes AND inline CSS const fs = require('fs'); const path = require('path'); const classMap = {}; const varMap = {}; // CSS variable minification map let idx = 0; let varIdx = 0; function shouldSkip(c) { // Icons if (c.includes('fa-') || c.includes('icon-fa')) return true; if (c.startsWith('not-')) return true; return false; } function getVarShortName(num) { // Map to --a, --b, --c, ... --z, --aa, --ab, etc. if (num < 26) return '--' + String.fromCharCode(97 + num); // a-z const letters = 'abcdefghijklmnopqrstuvwxyz'; let result = ''; num -= 26; result += letters[Math.floor(num / 26)]; result += letters[num % 26]; return '--' + result; } function stripPalette(html) { const before = html.length; // 1. Strip redundant ::backdrop reset (inherits from parent via *, :after, :before) html = html.replace(/::backdrop\{--tw-[^}]*\}/g, ''); // 2. Strip unused --tw-* variable declarations from base reset // These are empty/whitespace defaults that no utility class references via var() const baseResetMatch = html.match(/\*,?:after,:before\{([^}]*)\}/); if (baseResetMatch) { const block = baseResetMatch[0]; const decls = baseResetMatch[1].match(/--tw-[a-z0-9-]+:\s*[^;]+;/g) || []; // Find which --tw-* vars are referenced via var() anywhere in the HTML const restOfHTML = html.replace(block, ''); const unused = decls.filter(d => { const varName = d.split(':')[0].trim(); return !restOfHTML.includes('var(' + varName + ')'); }); if (unused.length > 0) { let newBlock = block; unused.forEach(d => { newBlock = newBlock.replace(d, ''); }); // Clean up empty declarations and trailing semicolons newBlock = newBlock.replace(/;\s*;/g, ';').replace(/;\}/, '}'); html = html.replace(block, newBlock); console.log('Stripped ' + unused.length + ' unused --tw-* variables'); } } // 2. Strip dead prose rules (elements never used on the site) const deadElements = ['pre', 'blockquote', 'kbd', 'table', 'thead', 'tbody', 'tfoot', 'figure', 'figcaption', 'dl', 'dt', 'dd', 'picture', 'video']; deadElements.forEach(el => { const regex = new RegExp('\\.prose :where\\([^)]*\\b' + el + '\\b[^)]*\\):not\\(:where\\(\\[class~=not-prose\\],\\[class~=not-prose\\] \\*\\)\\)\\{[^}]*\\}', 'g'); html = html.replace(regex, ''); }); // 3. Strip @font-face (embedded Libertinus Mono — use system monospace instead) html = html.replace(/@font-face\{[^}]*src:url\([^)]*\)[^}]*\}/g, ''); // 4. Strip unused prose variable declarations // Find the second .prose{ block (the one with --tw-prose-* variables) const proseVarBlock = html.match(/\.prose\{(--tw-prose[^}]*)\}/); if (proseVarBlock) { const block = proseVarBlock[0]; const vars = proseVarBlock[1].match(/--[a-z-]+:[^;]+/g) || []; // Check which vars are used in remaining prose rules const restOfHTML = html.replace(block, ''); const unused = vars.filter(v => { const varName = v.split(':')[0]; return !restOfHTML.includes('var(' + varName + ')'); }); if (unused.length > 0) { let newBlock = block; unused.forEach(v => { newBlock = newBlock.replace(v + ';', ''); }); html = html.replace(block, newBlock); } } if (html.length < before) { console.log('Palette optimized: ' + (before - html.length) + ' bytes'); } return html; } function minifyInlineScripts(html) { // Minify inline script blocks (strip comments and excess whitespace) return html.replace(/]*)?>([\s\S]*?)<\/script>/gi, (match, content) => { if (content.length < 50) return match; // Skip tiny scripts // Strip comments only when they occur outside JavaScript strings. A regex // for // would also match the protocol in URLs such as https://example.com. let withoutComments = ''; let quote = null; let escaped = false; for (let i = 0; i < content.length; i++) { const char = content[i]; const next = content[i + 1]; if (quote) { withoutComments += char; if (escaped) { escaped = false; } else if (char === '\\') { escaped = true; } else if (char === quote) { quote = null; } continue; } if (char === '"' || char === "'" || char === '`') { quote = char; withoutComments += char; } else if (char === '/' && next === '*') { const end = content.indexOf('*/', i + 2); i = end === -1 ? content.length : end + 1; } else if (char === '/' && next === '/') { const end = content.indexOf('\n', i + 2); i = end === -1 ? content.length : end - 1; } else { withoutComments += char; } } const minified = withoutComments .replace(/\s+/g, ' ') // collapse whitespace .replace(/\s*([{}();,])\s*/g, '$1') // remove space around punctuation .trim(); return match.replace(content, minified); }); } function processFile(filePath) { let html = fs.readFileSync(filePath, 'utf8'); let modified = false; // Optimize palette CSS before class renaming (selectors still say .prose) html = stripPalette(html); // Minify inline scripts html = minifyInlineScripts(html); const matches = html.match(/class="[^"]+"/g) || []; // Collect --tw-* variables (first file only, or reset per file - using global map) const varMatches = html.match(/--tw-[a-z0-9-]*/g) || []; varMatches.forEach(v => { if (!varMap[v]) varMap[v] = getVarShortName(varIdx++); }); // First pass: collect class mappings matches.forEach(m => { const arr = m.slice(7, -1).split(' ').filter(c => c); arr.forEach(c => { if (!shouldSkip(c) && !classMap[c]) classMap[c] = 'c' + idx++; }); }); // NOTE: do NOT alias dark:/responsive variants onto their base class token. // Sharing a token makes an element that carries the base class also match the // variant's CSS rule (equal specificity, source-order decides), and an aliased // `dark:` rule can sort AFTER an element's real dark variant — e.g. an element // with `text-primary-700 dark:text-primary-300` matches both // `.cX:where(.dark){#d6d3d1}` (real) and `.cY:where(.dark){#44403c}` (aliased // dark:text-primary-700) and keeps its light ink in dark. Every class — // including prefixed variants — gets its own unique index from the first pass. // Second pass: replace classes in HTML matches.forEach(m => { const arr = m.slice(7, -1).split(' ').filter(c => c); const newArr = arr.map(c => shouldSkip(c) ? c : classMap[c]); if (newArr.join(' ') !== arr.join(' ')) { html = html.replace(m, 'class="' + newArr.join(' ') + '"'); modified = true; } }); // Replace --tw-* variables in HTML and inline styles Object.entries(varMap).forEach(([orig, short]) => { const varRegex = new RegExp(orig.replace(/[-]/g, '\\-'), 'g'); if (html.match(varRegex)) { html = html.replace(varRegex, short); modified = true; } }); // Third pass: replace in ALL CSS Object.entries(classMap).forEach(([orig, short]) => { if (shouldSkip(orig)) return; // For regular classes: escape dashes, NOT brackets (CSS keeps them as-is) let escaped = orig.replace(/-/g, String.raw`\-`); // 1. Base class: .flex-row → .c6 const basePattern = String.raw`\.` + escaped; const baseRegex = new RegExp(basePattern + '(?=[{:, >.])', 'g'); html = html.replace(baseRegex, '.' + short); // 2. Dark variant: .dark\:flex-row → .c6 if (orig.startsWith('dark:')) { const baseClass = orig.slice(5); let baseEscaped = baseClass.replace(/-/g, String.raw`\-`); const darkPattern = String.raw`\.dark\\:` + baseEscaped; const darkRegex = new RegExp(darkPattern + '(?=[{:, >.])', 'g'); html = html.replace(darkRegex, '.' + short); } // 3. Responsive variant if (orig.match(/^(sm|md|lg|xl|2xl):/)) { const baseClass = orig.replace(/^(sm|md|lg|xl|2xl):/, ''); const prefix = orig.match(/^(sm|md|lg|xl|2xl):/)[1]; const baseEscaped = baseClass.replace(/-/g, String.raw`\-`); const respPattern = String.raw`\.` + prefix + String.raw`\\:` + baseEscaped; const respRegex = new RegExp(respPattern + '(?=[{:, >.])', 'g'); html = html.replace(respRegex, '.' + short); } // 4. Arbitrary value classes: w-[88px], max-w-[80ch], border-t-[6px] // In CSS: brackets are escaped as \[ and \], % is escaped as \% if (orig.includes('[')) { // Build the EXACT string as it appears in CSS // CSS has: max-w-\[85ch\] (literal backslash before [ and ]) // In regex, to match literal backslash + [, we need: \\\[ // But in JS string, that's '\\\\[' (two backslashes in string = one in regex) const cssClass = orig.replace(/\[/g, '\\\\[').replace(/\]/g, '\\\\]').replace(/%/g, '\\\\%'); const arbiPattern = '.' + cssClass; const arbiRegex = new RegExp(arbiPattern + '(?=[{:, >.])', 'g'); html = html.replace(arbiRegex, '.' + short); // Dark + arbitrary if (orig.startsWith('dark:')) { const baseClass = orig.slice(5); const cssClass2 = baseClass.replace(/\[/g, '\\\\[').replace(/\]/g, '\\\\]').replace(/%/g, '\\\\%'); const darkArbiPattern = '.dark\\\\:' + cssClass2; const darkArbiRegex = new RegExp(darkArbiPattern + '(?=[{:, >.])', 'g'); html = html.replace(darkArbiRegex, '.' + short); } // Responsive + arbitrary if (orig.match(/^(sm|md|lg|xl|2xl):/)) { const baseClass = orig.replace(/^(sm|md|lg|xl|2xl):/, ''); const prefix = orig.match(/^(sm|md|lg|xl|2xl):/)[1]; const cssClass2 = baseClass.replace(/\[/g, '\\\\[').replace(/\]/g, '\\\\]').replace(/%/g, '\\\\%'); const respArbiPattern = '.' + prefix + '\\\\:' + cssClass2; const respArbiRegex = new RegExp(respArbiPattern + '(?=[{:, >.])', 'g'); html = html.replace(respArbiRegex, '.' + short); } } }); // Fourth pass: handle CSS selectors with no- prefix classes // e.g., a:not(.no-external-icon) → a:not(.cXX) // e.g., a[data-no-external-icon] → a[data-cXX] Object.entries(classMap).forEach(([orig, short]) => { if (orig.startsWith('no-')) { // Replace .no-external-icon in CSS selectors const escaped = orig.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const selectorRegex = new RegExp('\\.' + escaped + '(?=[^a-zA-Z0-9]|$)', 'g'); html = html.replace(selectorRegex, '.' + short); // Also replace data-no-external-icon in attribute selectors: [data-no-external-icon] → [data-cXX] const attrRegex = new RegExp('\\[data-' + escaped + '\\]', 'g'); html = html.replace(attrRegex, '[data-' + short + ']'); // And data-no-external-icon="..." in HTML: data-no-external-icon="..." → data-cXX="..." const htmlAttrRegex = new RegExp('data-' + escaped + '=', 'g'); html = html.replace(htmlAttrRegex, 'data-' + short + '='); } }); // 5. Class-combination merging runs as a separate global pass (combineClassesAcrossSite) // at the end of the script, after every file is minified. if (modified) fs.writeFileSync(filePath, html); } // =========================================================================== // Global class-combination merging. // // Runs after per-file minification. The minifier has already produced short // per-class tokens (cN) and minified the inline CSS. Many pages repeat the same // *combination* of classes (cards, pills, nav rows). We: // 1. Mine frequent contiguous combinations of plain .cN{} utility tokens // (2..6 classes) across every page's class="..." attribute. // 2. For each candidate compute the byte economics per page: // replacing k tokens (length L) with one qN token saves (L - qLen) per // occurrence, at the cost of one appended `.qN{decls}` rule per page that // uses it. Only apply groups whose per-page net is positive. // 3. Rewrite those occurrences to the qN token, append the merged rule to the // page's CSS, and delete the now-unused plain `.cN{...}` member rules. // // Safety gates (the reasons the old pair-based pass regressed): // - Only tokens whose CSS rule is exactly a plain `.cN{...}` (single selector, // top-level, not inside @media, not referenced by any other selector such as // :hover, .dark , dark:, or .cN :where(descendant) prose selectors) may // participate. Variant classes now get their own distinct cN token (the // minifier no longer aliases dark:/responsive onto the base token), so // merging a base utility never duplicates a variant rule. // - Members must not repeat the same property (order-ambiguous union). // - Per occurrence, a merged group is skipped if any OTHER surviving class on // that element declares a property also declared by a member — injecting the // merged rule after the plain rules would otherwise change the cascade // winner for that element. // - A member rule is only deleted when the token is fully unused on the page // AND its declaration declares no CSS variables (vars like --ba/--bd are // referenced cross-token by other rules, e.g. color/bg tokens). // =========================================================================== function combineClassesAcrossSite() { const files = []; (function collect(dir) { if (!fs.existsSync(dir)) return; fs.readdirSync(dir).forEach(name => { const full = path.join(dir, name); const stat = fs.statSync(full); if (stat.isDirectory()) collect(full); else if (name.endsWith('.html')) files.push(full); }); })('_site'); if (files.length === 0) return null; // ---- Global CSS facts ---- const plainDecl = new Map(); // token -> rule decl (identical everywhere -> else AMBIG) const inSelector = new Set(); // token referenced by a compound/descendant selector const inMedia = new Set(); // token's .cN{} rule lives inside @media const pageHtml = new Map(); // file -> { html, css } for (const f of files) { const html = fs.readFileSync(f, 'utf8'); const css = [...html.matchAll(/ // before the doctype when the compress layout has stripped all tags. // Left there, it pushes the real past the browser encoding-sniff // window, so the page decodes as windows-1252 and UTF-8 smart punctuation // renders as ’/ï¬. Relocate that style into the real head (right after the // charset meta) and, for any remaining page, inject a charset meta early. const files = []; (function collect(dir) { if (!fs.existsSync(dir)) return; fs.readdirSync(dir).forEach(name => { const f = path.join(dir, name); const s = fs.statSync(f); if (s.isDirectory()) collect(f); else if (name.endsWith('.html')) files.push(f); }); })('_site'); for (const f of files) { const b = fs.readFileSync(f); const prefix = b.subarray(0, 1024).toString('latin1'); const hasCharset = /charset\s*=/.test(prefix); const original = b.toString('utf8'); let out = original; const preDoctype = out.match(/^(