import { createHash } from 'node:crypto'; import { promises as fs } from 'node:fs'; import path from 'node:path'; import { gzipSync } from 'node:zlib'; import { transform } from 'esbuild'; import { minify } from 'html-minifier-terser'; import sharp from 'sharp'; const rootDirectory = path.resolve(process.cwd()); const outputDirectory = path.resolve(process.env.OUTPUT_DIR || path.join(rootDirectory, 'dist')); const cssFiles = [ 'css/variables.css', 'css/main.css', 'css/button.css', 'css/matrix.css', 'css/sections/error.css', 'css/sections/hero.css', 'css/sections/homelab.css', 'css/sections/profile.css', 'css/sections/projects.css' ]; const jsFiles = [ 'js/matrix.js', 'js/social.js' ]; const htmlFiles = [ 'index.html', '404.html' ]; const versionedFiles = new Map(); function absoluteSourcePath(relativePath) { return path.join(rootDirectory, relativePath); } function absoluteOutputPath(relativePath) { return path.join(outputDirectory, relativePath); } function versionFor(buffer) { return createHash('sha256').update(buffer).digest('hex').slice(0, 12); } async function requireSourceFile(relativePath) { try { await fs.access(absoluteSourcePath(relativePath)); } catch { throw new Error('Required source file is missing: ' + relativePath); } } async function writeOutput(relativePath, content) { const targetPath = absoluteOutputPath(relativePath); await fs.mkdir(path.dirname(targetPath), { recursive: true }); await fs.writeFile(targetPath, content); } async function minifyCss(relativePath) { let source = await fs.readFile(absoluteSourcePath(relativePath), 'utf8'); const transformed = await transform(source, { loader: 'css', legalComments: 'none', minify: true }); await writeOutput(relativePath, transformed.code); versionedFiles.set(relativePath, versionFor(Buffer.from(transformed.code))); } async function minifyJavaScript(relativePath) { const source = await fs.readFile(absoluteSourcePath(relativePath), 'utf8'); const transformed = await transform(source, { loader: 'js', legalComments: 'none', minify: true, target: 'es2020' }); await writeOutput(relativePath, transformed.code); versionedFiles.set(relativePath, versionFor(Buffer.from(transformed.code))); } async function copyStaticFile(relativePath) { const content = await fs.readFile(absoluteSourcePath(relativePath)); await writeOutput(relativePath, content); versionedFiles.set(relativePath, versionFor(content)); } function isExternalReference(reference) { return /^(?:[a-z][a-z0-9+.-]*:|\/\/|#|data:)/i.test(reference); } function versionedReference(reference) { if (isExternalReference(reference)) { return reference; } const isAbsolute = reference.startsWith('/'); const relativePath = (isAbsolute ? reference.slice(1) : reference).split(/[?#]/)[0]; const version = versionedFiles.get(relativePath); if (!version) { return reference; } return (isAbsolute ? '/' : '') + relativePath + '?v=' + version; } function replaceProductionReferences(html, avatarVersion) { return html.replace(/\b(?:href|src)=(["'])([^"']+)\1/g, (match, quote, reference) => { if (reference === '/assets/lain.png') { return match.replace(reference, '/assets/lain.webp?v=' + avatarVersion); } const replacement = versionedReference(reference); return replacement === reference ? match : match.replace(reference, replacement); }); } async function minifyHtml(relativePath, avatarVersion) { const source = await fs.readFile(absoluteSourcePath(relativePath), 'utf8'); const withProductionAssets = replaceProductionReferences(source, avatarVersion); const compactHtml = await minify(withProductionAssets, { collapseWhitespace: true, minifyCSS: false, minifyJS: false, removeComments: true, removeRedundantAttributes: true, sortAttributes: false, sortClassName: false, useShortDoctype: true }); await writeOutput(relativePath, compactHtml); } async function assertHtmlReferencesResolve(relativePath) { const html = await fs.readFile(absoluteOutputPath(relativePath), 'utf8'); const references = html.matchAll(/\b(?:href|src)=(["'])([^"']+)\1/g); for (const referenceMatch of references) { const reference = referenceMatch[2]; if (isExternalReference(reference)) { continue; } const relativePath = (reference.startsWith('/') ? reference.slice(1) : reference).split(/[?#]/)[0]; const targetPath = path.resolve(outputDirectory, relativePath); const relativeTarget = path.relative(outputDirectory, targetPath); if (relativeTarget.startsWith('..') || path.isAbsolute(relativeTarget)) { throw new Error('Production reference escapes the output directory: ' + reference); } try { await fs.access(targetPath); } catch { throw new Error('Production reference does not resolve: ' + reference); } } } async function gzipFile(relativePath) { const source = await fs.readFile(absoluteOutputPath(relativePath)); await fs.writeFile(absoluteOutputPath(relativePath) + '.gz', gzipSync(source, { level: 9, mtime: 0 })); } async function byteSize(relativePath) { return (await fs.stat(absoluteOutputPath(relativePath))).size; } async function build() { const requiredFiles = [ ...cssFiles, ...jsFiles, ...htmlFiles, 'assets/favicon.ico', 'assets/lain.png' ]; await Promise.all(requiredFiles.map(requireSourceFile)); await fs.rm(outputDirectory, { force: true, recursive: true }); await fs.mkdir(outputDirectory, { recursive: true }); for (const cssFile of cssFiles) { await minifyCss(cssFile); } for (const jsFile of jsFiles) { await minifyJavaScript(jsFile); } await copyStaticFile('assets/favicon.ico'); const optimizedAvatar = 'assets/lain.webp'; await fs.mkdir(path.dirname(absoluteOutputPath(optimizedAvatar)), { recursive: true }); await sharp(absoluteSourcePath('assets/lain.png')) .resize(320, 320, { fit: 'cover', position: 'centre' }) .webp({ effort: 4, quality: 82 }) .toFile(absoluteOutputPath(optimizedAvatar)); const avatarVersion = versionFor(await fs.readFile(absoluteOutputPath(optimizedAvatar))); for (const htmlFile of htmlFiles) { await minifyHtml(htmlFile, avatarVersion); } await Promise.all(htmlFiles.map(assertHtmlReferencesResolve)); const compressedFiles = [ ...cssFiles, ...jsFiles, ...htmlFiles ]; await Promise.all(compressedFiles.map(gzipFile)); const avatarBytes = await byteSize(optimizedAvatar); if (avatarBytes > 100 * 1024) { throw new Error('Optimized avatar exceeds the 100 KiB budget: ' + avatarBytes + ' bytes'); } const firstPartyFiles = [ 'index.html.gz', ...cssFiles.map((file) => file + '.gz'), ...jsFiles.map((file) => file + '.gz'), optimizedAvatar, 'assets/favicon.ico' ]; const firstPartyBytes = (await Promise.all(firstPartyFiles.map(byteSize))) .reduce((total, size) => total + size, 0); if (firstPartyBytes > 200 * 1024) { throw new Error('First-party transfer exceeds the 200 KiB budget: ' + firstPartyBytes + ' bytes'); } console.log(JSON.stringify({ avatarBytes, firstPartyBytes, outputDirectory })); } await build();