Files
ivanch.me/utils/build-production.mjs
Jose Henrique 72c7d6c9e0
Some checks failed
Homepage Build and Deploy (Test) / Build Homepage Image (push) Successful in 32s
Homepage Build and Deploy (Test) / Deploy Homepage (push) Failing after 3s
new pipeline and build improvements
2026-07-10 17:06:16 -03:00

251 lines
7.6 KiB
JavaScript

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();