new pipeline and build improvements
This commit is contained in:
250
utils/build-production.mjs
Normal file
250
utils/build-production.mjs
Normal file
@@ -0,0 +1,250 @@
|
||||
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();
|
||||
Reference in New Issue
Block a user