Skip to content

Instantly share code, notes, and snippets.

@ferares
Last active September 13, 2021 21:30
Show Gist options
  • Save ferares/5ddbf90fbecc6e47d748a5b9cd55c172 to your computer and use it in GitHub Desktop.
Save ferares/5ddbf90fbecc6e47d748a5b9cd55c172 to your computer and use it in GitHub Desktop.
const babelify = require('babelify');
const browserify = require('browserify');
const browserSync = require('browser-sync').create();
const chokidar = require('chokidar');
const crypto = require('crypto');
const fs = require('fs-extra');
const modifyFilename = require('modify-filename');
const path = require('path');
const phpServer = require('php-server');
const sass = require('sass');
const through = require('through2');
const params = process.argv.slice(2);
const config = {
dest: './dist',
host: '0.0.0.0',
port: 2000,
prod: params.indexOf('--prod') > -1,
serve: params.indexOf('--serve') > -1,
};
const paths = {
styles: {
watch: './src/styles/',
bundles: [
{
name: 'main.css',
entry: './src/styles/main.scss',
},
],
dest: `${config.dest}/styles`,
},
scripts: {
watch: './src/scripts/',
bundles: [
{
name: 'main.js',
files: ['./src/scripts/main.js'],
},
],
dest: `${config.dest}/scripts`,
},
assets: {
watch: './src/assets/',
files: ['./src/assets/'],
dest: `${config.dest}/assets`,
},
files: {
watch: ['./src/files/', './src/views/'],
files: ['./src/files/', './src/views/'],
dest: config.dest,
},
};
const revQueues = {};
/**
* Queues an entry to be added to a revision manifest
* @param {string} path path to the manifest file
* @param {object} entry object with the key and value of the entry to be added
* @param {function} cb callback function to be called after the entry is added
*/
function revQueueAdd(path, entry, cb) {
// If there's no queue for the manifest create one
if (revQueues[path] === undefined) {
revQueues[path] = [];
}
// Add the entry to the manifest's queue
revQueues[path].push({ entry: entry, cb: cb });
// If there's only one item on the queue add it to the manifest
if (revQueues[path].length === 1) {
revQueueNext(path);
}
}
/**
* Adds the next entry on a manifest's queue to that manifest
* @param {string} path path to the manifest file
*/
function revQueueNext(path) {
// If there are no items on the queue return
if (revQueues[path].length === 0) return;
// Get the next element on the queue
const element = revQueues[path][0];
// Read the manifest file
fs.readJson(path, (err, manifest) => {
// If there's no manifest initalize it
if (err) manifest = {};
// Add the entry to the manifest
manifest[element.entry.key] = element.entry.value;
// Write the updated manifest file
fs.outputJson(path, manifest).then(() => {
// Remove the porcessed entry from the queue
revQueues[path].shift();
// Call the entry's callback function
element.cb();
// Add the next item on the queue
revQueueNext(path);
});
});
}
/**
* Replace any occurrence of a manifest's entries on a given string
* @param {string} content the content to parse for occurrences
* @param {object} manifest the manifest
*
* @return {string} the modified content
*/
function revReplace(content, manifest) {
// For every key on the manifest
for (var key in manifest) {
// If the key is found on the given content
if ((manifest.hasOwnProperty(key)) && (content.indexOf(key) > -1)) {
// Replace all the key's occurences with the value associated to it on the manifest
content = content.replace(new RegExp(key, 'g'), manifest[key]);
}
}
// Return the modified content
return content;
}
/**
* Browserify transform for replacing any occurrence of a manifest's entry on a given string
* @param {any} file the file being processed by browserify
* @param {object} config browserify config including a manifest property with the path to the manifest
*/
function revReplaceify(file, config) {
return through(function (buf, enc, next) {
// Read the manifest file
fs.readJson(config.manifest).then((manifest) => {
// Replace any occurrence of the manifest's entries
this.push(revReplace(buf.toString('utf8'), manifest));
next();
});
});
}
/**
* Renames a file to include it's MD5 hash on its filename
* @param {array} files the files to be renamed
* @param {string} manifestPath the path to the manifest on which to reference the old and new names
*/
function rev(files, manifestPath) {
return new Promise((resolve, reject) => {
const promises = [];
// For each file
for (const file of files) {
// Calculate it's MD5 hash
const hash = crypto.createHash('md5').update(file.content).digest('hex').slice(0, 10);
// Change it's name to include the hash
const newPath = modifyFilename(file.path, (filename, ext) => `${filename}-${hash}${ext}`);
// Rename the file
fs.rename(file.path, newPath);
promises.push(new Promise((resolve, reject) => {
// Add an entry to the manifest linking it's old name to the new one
revQueueAdd(manifestPath, { key: path.relative(file.base, file.path), value: path.relative(file.base, newPath) }, resolve);
}));
}
Promise.all(promises).then(resolve).catch(reject);
});
}
/**
* Deletes the output "dest" directory with all it's contents
*/
function clean() {
console.time('Cleaning done after');
console.log('Cleaning...');
return new Promise((resolve, reject) => {
fs.rm(config.dest, { recursive: true, force: true }).then(() => {
console.timeEnd('Cleaning done after');
resolve();
}).catch(reject);
});
}
/**
* Process al the SCSS files from paths.styles.bundles and outputs CSS files on paths.styles.dest
*/
function buildStyles() {
console.time('Building styles done after');
console.log('Building styles...');
return new Promise((resolve, reject) => {
const promises = [];
// For each entry file
for (const bundle of paths.styles.bundles) {
// Destination CSS file
const outputFilePath = `${paths.styles.dest}/${bundle.name}`;
promises.push(new Promise((resolve, reject) => {
// Render the SASS files
return sass.render({
file: bundle.entry,
outFile: outputFilePath,
outputStyle: config.prod ? 'compressed' : 'expanded',
sourceMap: !config.prod,
sourceMapEmbed: !config.prod,
}, (err, res) => {
if (err) return reject(err);
resolve({ name: bundle.name, output: res });
});
}));
}
// After the files are rendered
Promise.all(promises).then((values) => {
let manifest = '';
if (config.prod) {
// If we're building for pord get the assets manifest
manifest = fs.readJsonSync(`${config.dest}/manifest-assets.json`);
}
const promises = [];
const files = [];
// For each file
for (var value of values) {
let content = value.output.css;
if (config.prod) {
// If we're building for pord update any reference to any asset with its "hased" filename
content = revReplace(content.toString('utf8'), manifest);
}
// Write the rendered CSS to a file
const outputFilePath = `${paths.styles.dest}/${value.name}`;
promises.push(fs.outputFile(outputFilePath, content));
if (config.prod) {
// If we're building for pord add the file to the array of files to be "hashed"
files.push({ path: outputFilePath, base: paths.styles.dest, content: content });
}
}
// After all the files have been writen
Promise.all(promises).then(() => {
if (config.prod) {
// If we're building for pord "hash" the files
rev(files, `${config.dest}/manifest-styles.json`).then(() => {
console.timeEnd('Building styles done after');
resolve();
});
} else {
// If we're not building for pord we're done
console.timeEnd('Building styles done after');
resolve();
}
}).catch(reject);
});
});
}
/**
* Process al the JS files from paths.scripts.bundles and outputs browser ready JS files on paths.scripts.dest
*/
function buildScripts() {
console.time('Building scripts done after');
console.log('Building scripts...');
return new Promise((resolve, reject) => {
// Create the destination directory for the scripts
fs.mkdir(paths.scripts.dest, { recursive: true }).then(() => {
const promises = [];
// For each designated bundle
for (const bundle of paths.scripts.bundles) {
const outputFilePath = `${paths.scripts.dest}/${bundle.name}`;
promises.push(new Promise((resolve, reject) => {
// Use browserify to transpile and optimize the JS
const browserifyInstance = browserify(bundle.files, { debug: !config.prod })
.transform(babelify.configure({ presets: ['@babel/preset-env'] }));
// Production only optimizations
if (config.prod) {
browserifyInstance
.transform(revReplaceify, { manifest: `${config.dest}/manifest-assets.json` })
.transform('unassertify', { global: true })
.transform('@goto-bus-stop/envify', { global: true })
.transform('uglifyify', { global: true, sourceMap: false })
.plugin('common-shakeify')
// https://github.com/goto-bus-stop/browser-pack-flat/issues/39
// .plugin('browser-pack-flat/plugin')
.on('bundle', () => {
// After the bundle has been processed "hash" it
fs.readFile(outputFilePath).then((content) => {
rev([{ path: outputFilePath, base: paths.scripts.dest, content: content }], `${config.dest}/manifest-scripts.json`);
});
});
}
const browserifyBundle = browserifyInstance.bundle();
if (config.prod) {
// Production only optimization for the bundle
browserifyBundle.pipe(require('minify-stream')({ sourceMap: false }))
}
browserifyBundle.pipe(fs.createWriteStream(outputFilePath))
.on('finish', resolve)
.on('error', reject);
}));
}
Promise.all(promises).then(() => {
// After all bundles have been processed we're done
console.timeEnd('Building scripts done after');
resolve();
}).catch(reject);
});
});
}
/**
* Copies all files from paths.files.files to paths.files.dest
*/
function buildFiles() {
console.time('Building files done after');
console.log('Building files...');
return new Promise((resolve, reject) => {
const promises = [];
for (const path of paths.files.files) {
promises.push(fs.copy(path, paths.files.dest));
}
Promise.all(promises).then(() => {
console.timeEnd('Building files done after');
resolve();
}).catch(reject);
});
}
/**
* Copies an asset file to paths.assets.dest
* @param {string} asset the path to the asset file
* @param {string} base the base path of the file
*/
function buildAssetFile(asset, base) {
return new Promise((resolve, reject) => {
// The destination path should include the subfolders after the base
const filename = `${paths.assets.dest}/${path.relative(base, asset)}`;
// Create the destination directory if it doesn't exist
fs.mkdir(path.dirname(filename), { recursive: true }).then(() => {
// Copy the file over
fs.copyFile(asset, filename).then(() => {
if (config.prod) {
// If we're building for prod we need to "hash" the filename
fs.readFile(asset).then((content) => {
const file = { path: filename, base: paths.assets.dest, content: content };
rev([file], `${config.dest}/manifest-assets.json`).then(resolve);
});
} else {
resolve();
}
});
});
});
}
/**
* Copies an asset folder to paths.assets.dest
* @param {string} folder the path to the asset folder
* @param {string} base the base path of the folder
*/
function buildAssetFolder(folder, base) {
return new Promise((resolve, reject) => {
// Read the directory
fs.readdir(folder).then((elements) => {
const promises = [];
// For each element
for (const element of elements) {
promises.push(new Promise((resolve, reject) => {
const asset = `${folder}/${element}`;
// Check to see if it's a file or a directory
fs.lstat(asset).then((stats) => {
if (stats.isFile()) {
// If it's a file process it
buildAssetFile(asset, base).then(resolve).catch(reject);
} else {
// If it's a folder recursively process it
buildAssetFolder(asset, base).then(resolve).catch(reject);
}
});
}));
}
Promise.all(promises).then(resolve).catch(reject);
});
});
}
/**
* Copies all assets from paths.assets.files to paths.assets.dest
*/
function buildAssets() {
console.time('Building assets done after');
console.log('Building assets...');
return new Promise((resolve, reject) => {
fs.mkdir(paths.assets.dest, { recursive: true }).then(() => {
const promises = [];
const elements = paths.assets.files;
// For each asset element
for (const element of elements) {
promises.push(new Promise((resolve, reject) => {
// Check to see if it's a file or a directory
fs.lstat(element).then((stats) => {
const base = path.normalize(element);
if (stats.isFile()) {
// If it's a file process it
buildAssetFile(element, base).then(resolve).catch(reject);
} else {
// If it's a folder recursively process it
buildAssetFolder(element, base).then(resolve).catch(reject);
}
});
}));
}
Promise.all(promises).then(() => {
console.timeEnd('Building assets done after');
resolve();
}).catch(reject);
});
});
}
/**
* Watches a file or directory for changes and calls an action when changes are made
* @param {string} path the path to the element to watch
* @param {function} action the function to call when changes occur
*/
function watch(path, action) {
chokidar.watch(path).on('change', () => {
action(); // Call the action
browserSync.reload(); // Reload the browser
});
}
/**
* Watches for changes on paths.files.watch, paths.styles.watch, paths.scripts.watch and paths.assets.watch
*/
function watchAll() {
console.log('Watching for changes...');
watch(paths.files.watch, buildFiles);
watch(paths.styles.watch, buildStyles);
watch(paths.scripts.watch, buildScripts);
watch(paths.assets.watch, () => {
buildAssets().then(() => {
// If we're building for prod we need to rebuild the scripts and styles too
// in case there are references to assets files in them
if (config.prod) {
buildStyles();
buildScripts();
}
});
});
}
/**
* Starts a PHP dev server and a browserSync proxy server for live reloading
*/
function serve() {
console.time('Starting local dev server done after');
console.log('Starting local dev server...');
return new Promise((resolve, reject) => {
phpServer({
port: config.port,
hostname: config.host,
base: config.dest,
}).then((server) => {
browserSync.init({
proxy: `${config.host}:${config.port}`,
host: config.host,
port: config.port + 1,
ghostMode: {
clicks: false,
forms: false,
scroll: false,
},
}, () => {
console.timeEnd('Starting local dev server done after');
// Log the PHP server's messages
server.stderr.on('data', (data) => process.stdout.write(data));
resolve();
});
});
});
}
// Build the project
console.log(`Building project for ${config.prod ? 'PRODUCTION' : 'DEVELOPMENT'}`);
clean().then(() => {
buildAssets().then(() => {
const promises = [
buildFiles(),
buildStyles(),
buildScripts(),
];
if (config.serve) {
// If the serve option was passed serve and watch the project
Promise.all(promises).then(() => {
serve().then(() => {
watchAll();
});
});
}
});
});
@ferares
Copy link
Author

ferares commented Sep 13, 2021

This is a script for building PHP web projects that use SASS and ES7+ JS

Features:

  • SASS to CSS using Dart Sass.
  • JS transpilation using Babel bundling with Browserify and optimizations using Tinyify.
  • Dev server using php-server and Browsersync.
  • Output of filenames with hashes and manifest files for cache busting with reference replacement for JS and SCSS files.

Required packages:

  • @babel/core
  • @babel/preset-env
  • babelify
  • browser-sync
  • browserify
  • chokidar
  • crypto
  • fs-extra
  • modify-filename
  • php-server
  • sass
  • through2
  • tinyify

npm i --save-dev @babel/core @babel/preset-env babelify browser-sync browserify chokidar crypto fs-extra modify-filename php-server sass through2 tinyify

Project structure:
-src
--assets
--files
--scripts
--styles
--views

Commands:

  • node build builds the project for development into the ./dist folder
  • node build --serve builds the project for development into the ./dist folder and starts a development web server
  • node build --prod builds the project for production into the ./dist folder
  • node build --prod --serve builds the project for production into the ./dist folder and starts a development web server

Notes:

  • The development builds include source maps while the production builds don't
  • The production builds include optimizations for JS and CSS such as uglification and tree shaking.
  • The production builds output filenames with hashes and manifest files, while also replacing any references to assets from within any CSS or JS file.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment