rev-web-assets
Version:
Revision web asset filenames with cache busting content hash fingerprints
245 lines (243 loc) • 11.9 kB
JavaScript
//! rev-web-assets v1.6.0 ~~ https://github.com/center-key/rev-web-assets ~~ MIT License
import { cliArgvUtil } from 'cli-argv-util';
import os from 'node:os';
import chalk from 'chalk';
import crypto from 'node:crypto';
import fs from 'node:fs';
import log from 'fancy-log';
import path from 'node:path';
import slash from 'slash';
const revWebAssets = {
version: '1.6.0',
assertOk(ok, message) {
if (!ok)
throw new Error(`[rev-web-assets] ${message}`);
},
manifest(source, target, skip) {
const files = fs.readdirSync(source, { recursive: true })
.map(file => slash(path.join(source, file.toString())))
.filter(file => fs.statSync(file).isFile())
.sort();
const process = (file) => {
const fileExtension = path.extname(file).toLowerCase();
const isHtml = ['.html', '.htm', '.php'].includes(fileExtension);
const isCss = ['.css'].includes(fileExtension);
const canonical = file.substring(source.length + 1);
const canonicalFolder = path.dirname(canonical).replace(/\.$/, '');
const destFolder = !canonicalFolder ? target : target + '/' + canonicalFolder;
return {
origin: file,
filename: path.basename(file),
canonicalFolder: canonicalFolder,
canonical: canonical,
bytes: null,
isHtml: isHtml,
isCss: isCss,
hash: null,
hashedFilename: null,
destFolder: destFolder,
destPath: null,
usedIn: isHtml ? null : [],
references: isHtml ? null : 0,
skipped: !isHtml && !!skip && file.includes(skip),
missing: isHtml || isCss ? [] : null,
};
};
const manifest = files.map(process);
return manifest;
},
hashFilename(filename, hash) {
const lastDot = /\.(?=[^.]*$)/;
return slash(path.normalize(!hash ? filename : filename.replace(lastDot, '.' + hash + '.')));
},
stripHash(filename) {
return filename.replace(/[.][0-9a-f]{8}[.]/, '.');
},
readJustFiles(folder) {
const isFile = (file) => fs.statSync(folder + '/' + file).isFile();
return fs.readdirSync(folder).filter(isFile).map(revWebAssets.stripHash).sort();
},
calcAssetHash(detail) {
const hashLen = 8;
const brokenWindows = /$\r\n/gm;
const cleanUpText = (buffer) => buffer.toString().replace(brokenWindows, '\n');
const contents = cleanUpText(fs.readFileSync(detail.origin));
const hash = crypto.createHash('md5').update(contents).digest('hex');
detail.bytes = contents.length;
detail.hash = hash.substring(0, hashLen);
detail.hashedFilename = revWebAssets.hashFilename(detail.filename, detail.hash);
return detail;
},
hashAssetPath(manifest, detail, settings) {
const webPages = ['.html', '.htm', '.php'];
const replacer = (matched, pre, url, post) => {
const line = matched.replace(/\s+/g, ' ');
const uri = url.replace(/[#?].*/, '');
const ext = path.extname(uri);
const isTemplate = /{.*}|<.*>|~~.*~~/.test(uri);
const unhashable = uri.includes(':') || webPages.includes(ext) || ext.length < 2;
const canonicalPath = detail.canonicalFolder ? detail.canonicalFolder + '/' : '';
const canonical = slash(path.normalize(canonicalPath + uri));
const isAssetDetail = (detail) => detail.canonical === canonical;
const assetDetail = isTemplate || unhashable ? null : manifest.find(isAssetDetail);
const skipAsset = !!settings.skip && uri.includes(settings.skip);
if (assetDetail && !assetDetail.hash && !skipAsset)
revWebAssets.calcAssetHash(assetDetail);
if (assetDetail)
assetDetail.references++;
if (assetDetail && !assetDetail.usedIn.includes(detail.canonical))
assetDetail.usedIn.push(detail.canonical);
if (!isTemplate && !unhashable && !skipAsset && !assetDetail)
detail.missing.push({ ext, line });
const trailingSlashes = /\/*$/;
const metaContentBase = settings.metaContentBase?.replace(trailingSlashes, '/');
const absoluteUrl = () => `${metaContentBase}${assetDetail?.canonicalFolder}/${assetDetail?.hashedFilename}`;
const hashedUri = () => {
const noBase = !settings.metaContentBase || !pre.startsWith('<meta');
return noBase ? revWebAssets.hashFilename(uri, assetDetail.hash) : absoluteUrl();
};
return assetDetail?.hash ? pre + hashedUri() + post : matched;
};
return replacer;
},
processHtml(manifest, settings) {
const hrefPattern = /(<[a-z]{1,4}\s.*href=['"]?)([^"'>\s]*)(['"]?[^<]*>)/ig;
const srcPattern = /(<[a-z]{3,6}\s.*src=['"]?)([^"'>\s]*)(['"]?[^<]*>)/ig;
const metaPattern = /(<meta\s.*content=['"]?)([^"'>\s]*)(['"]?[^<]*>)/ig;
const process = (detail) => {
const content = fs.readFileSync(detail.origin, 'utf-8');
const calcNext = () => revWebAssets.hashAssetPath(manifest, detail, settings);
const hashedContent = content
.replace(hrefPattern, calcNext())
.replace(srcPattern, calcNext())
.replace(metaPattern, calcNext());
detail.destPath = `${detail.destFolder}/${detail.filename}`;
fs.mkdirSync(detail.destFolder, { recursive: true });
fs.writeFileSync(detail.destPath, hashedContent);
};
manifest.filter(detail => detail.isHtml).forEach(process);
},
processCss(manifest, settings) {
const urlPattern = /(url\(["']?)([^)('"]*)(["']?\))/ig;
const process = (detail) => {
const content = fs.readFileSync(detail.origin, 'utf-8');
const calcNext = () => revWebAssets.hashAssetPath(manifest, detail, settings);
const hashedContent = content.replace(urlPattern, calcNext());
const filename = detail.hashedFilename ?? detail.filename;
detail.destPath = `${detail.destFolder}/${filename}`;
fs.mkdirSync(detail.destFolder, { recursive: true });
fs.writeFileSync(detail.destPath, hashedContent);
};
manifest.filter(detail => detail.isCss).forEach(process);
},
copyAssets(manifest) {
const process = (detail) => {
detail.destPath = `${detail.destFolder}/${detail.hashedFilename ?? detail.filename}`;
fs.mkdirSync(detail.destFolder, { recursive: true });
fs.copyFileSync(detail.origin, detail.destPath);
};
manifest.filter(file => !file.isHtml && !file.isCss).forEach(process);
},
revision(sourceFolder, targetFolder, options) {
const defaults = {
cd: null,
force: false,
metaContentBase: null,
saveManifest: false,
skip: null,
};
const settings = { ...defaults, ...options };
const startTime = Date.now();
const cleanPath = (folder) => slash(path.normalize(folder.trim())).replace(/\/$/, '');
const startFolder = settings.cd ? cleanPath(settings.cd) + '/' : '';
const source = cleanPath(startFolder + sourceFolder);
const target = cleanPath(startFolder + targetFolder);
if (targetFolder)
fs.mkdirSync(target, { recursive: true });
const error = !sourceFolder ? 'Must specify the source folder path.' :
!targetFolder ? 'Must specify the target folder path.' :
!fs.existsSync(source) ? 'Source folder does not exist: ' + source :
!fs.existsSync(target) ? 'Target folder cannot be created: ' + target :
!fs.statSync(source).isDirectory() ? 'Source is not a folder: ' + source :
!fs.statSync(target).isDirectory() ? 'Target is not a folder: ' + target :
null;
revWebAssets.assertOk(!error, error);
const manifest = revWebAssets.manifest(source, target, settings.skip);
revWebAssets.processHtml(manifest, settings);
revWebAssets.processCss(manifest, settings);
const hashUnusedAsset = (detail) => !detail.hash && !detail.isHtml && !detail.skipped && revWebAssets.calcAssetHash(detail);
if (settings.force)
manifest.forEach(hashUnusedAsset);
revWebAssets.copyAssets(manifest);
manifest.forEach(detail => detail.usedIn && detail.usedIn.sort());
const manifestPath = path.join(target, 'manifest.json');
const indent = ' ';
const toJson = (data) => JSON.stringify(data, null, indent).replace(/\r?\n/g, os.EOL) + os.EOL;
if (settings.saveManifest)
fs.writeFileSync(manifestPath, toJson(manifest));
return {
source: source,
target: target,
count: manifest.length,
duration: Date.now() - startTime,
manifest: manifest,
};
},
reporter(results, options) {
const defaults = {
summaryOnly: false,
hide404s: false,
};
const settings = { ...defaults, ...options };
const name = chalk.gray('rev-web-assets');
const version = chalk.gray('v' + revWebAssets.version);
const infoColor = results.count ? chalk.white : chalk.red.bold;
const summary = infoColor(`(files: ${results.count}, ${results.duration}ms)`);
const symbol = {
arrow: chalk.gray.bold('→'),
checkmark: chalk.green.bold('✔'),
};
log(name, version, results.source, summary);
const logDetail = (detail, index) => {
const target = cliArgvUtil.colorizePath(detail.destPath);
const checkmark = detail.hash ? symbol.checkmark : '';
const file = chalk.blue.bold(detail.origin);
log(name, chalk.magenta(index + 1), target, checkmark);
const warning = (ext) => chalk.red.bold(`missing ${ext} asset in`);
const logMissingAsset = (missing) => log(name, warning(missing.ext), file, symbol.arrow, chalk.green(missing.line));
if (!settings.hide404s && detail.missing)
detail.missing.forEach(logMissingAsset);
};
if (!settings.summaryOnly)
results.manifest.forEach(logDetail);
return results;
},
cli() {
const validFlags = ['cd', 'force', 'hide404s', 'manifest', 'meta-content-base', 'note',
'quiet', 'skip', 'summary'];
const cli = cliArgvUtil.parse(validFlags);
const source = cli.params[0];
const target = cli.params[1];
const error = cli.invalidFlag ? cli.invalidFlagMsg :
!source ? 'Missing source folder.' :
!target ? 'Missing target folder.' :
cli.paramCount > 2 ? 'Extraneous parameter: ' + cli.params[2] :
null;
revWebAssets.assertOk(!error, error);
const options = {
cd: cli.flagMap.cd ?? null,
force: cli.flagOn.force,
metaContentBase: cli.flagMap.metaContentBase ?? null,
saveManifest: cli.flagOn.manifest,
skip: cli.flagMap.skip ?? null,
};
const results = revWebAssets.revision(source, target, options);
const reporterOptions = {
summaryOnly: cli.flagOn.summary,
hide404s: cli.flagOn.hide404s,
};
if (!cli.flagOn.quiet)
revWebAssets.reporter(results, reporterOptions);
},
};
export { revWebAssets };