diff --git a/README.md b/README.md index cd0da53..e81d918 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ npm start ``` ## Configuration -Change the mentions of `locomotive-boilerplate` for your project's name in `mconfig.json`. Legacy from [modularBP](https://github.com/modularorg/modularbp). +Rename occurrences of `locomotive-boilerplate` with your project's name in `loconfig.json`. ## Build diff --git a/build/build.js b/build/build.js index d6243d4..d835c6b 100644 --- a/build/build.js +++ b/build/build.js @@ -1,9 +1,9 @@ -import { concatVendors } from './tasks/concat.js'; +import { concatFiles } from './tasks/concats.js'; import { compileScripts } from './tasks/scripts.js'; import { compileStyles } from './tasks/styles.js' ; import { compileSVGs } from './tasks/svgs.js' ; -concatVendors(); +concatFiles(); compileScripts(); compileStyles(); compileSVGs(); diff --git a/build/tasks/concat.js b/build/tasks/concat.js deleted file mode 100644 index 70939e8..0000000 --- a/build/tasks/concat.js +++ /dev/null @@ -1,49 +0,0 @@ -import paths from '../mconfig.json'; -import message from '../utils/message.js'; -import notification from '../utils/notification.js'; -import concat from 'concat'; -import { readdir } from 'node:fs/promises'; - -/** - * Concatenates third-party JavaScript files. - */ -export async function concatVendors() { - const filename = 'vendors.js'; - const outfile = paths.scripts.dest + filename; - const external = [ - // Add files in node_modules example: - // 'node_modules/gsap/dist/gsap.min.js', - ]; - - const timeLabel = `${filename} concatenated in`; - console.time(timeLabel); - - try { - // Get all files in `scripts/vendors/` - let files = await readdir(paths.scripts.vendors.src); - - if (files.length) { - // Exclude files that are not JavaScript - files = files.filter((file) => file.includes('.js')); - - // Prepend absolute path - files = files.map((file) => paths.scripts.vendors.src + file); - } - - if (external.length) { - files = files.concat(external); - } - - await concat(files, outfile); - - message(`${filename} concatenated`, 'success', timeLabel); - } catch (err) { - message(`Error concatenating ${filename}`, 'error'); - message(err); - - notification({ - title: `${filename} concatenation failed 🚨`, - message: `${err.name}: ${err.message}` - }); - } -}; diff --git a/build/tasks/concats.js b/build/tasks/concats.js new file mode 100644 index 0000000..8ed6283 --- /dev/null +++ b/build/tasks/concats.js @@ -0,0 +1,45 @@ +import loconfig from '../../loconfig.json'; +import glob from '../utils/glob.js'; +import message from '../utils/message.js'; +import notification from '../utils/notification.js'; +import template from '../utils/template.js'; +import concat from 'concat'; +import { basename } from 'node:path'; + +/** + * Concatenates groups of files. + */ +export async function concatFiles() { + loconfig.tasks.concats.forEach(async ({ + includes, + outfile + }) => { + const filename = basename(outfile || 'undefined'); + + const timeLabel = `${filename} concatenated in`; + console.time(timeLabel); + + try { + includes = includes.map((path) => template(path)); + outfile = template(outfile); + + const files = await glob(includes); + + await concat(files, outfile); + + if (files.length) { + message(`${filename} concatenated`, 'success', timeLabel); + } else { + message(`${filename} is empty`, 'notice', timeLabel); + } + } catch (err) { + message(`Error concatenating ${filename}`, 'error'); + message(err); + + notification({ + title: `${filename} concatenation failed 🚨`, + message: `${err.name}: ${err.message}` + }); + } + }); +}; diff --git a/build/tasks/scripts.js b/build/tasks/scripts.js index 5397166..cfb344d 100644 --- a/build/tasks/scripts.js +++ b/build/tasks/scripts.js @@ -1,40 +1,58 @@ -import paths from '../mconfig.json'; +import loconfig from '../../loconfig.json'; import message from '../utils/message.js'; import notification from '../utils/notification.js'; +import template from '../utils/template.js'; import esbuild from 'esbuild'; +import { basename } from 'node:path'; /** * Bundles and minifies main JavaScript files. */ export async function compileScripts() { - [ - 'app.js', - ].forEach((filename) => { - const includes = [ paths.scripts.src + filename ]; - const outfile = paths.scripts.dest + filename; + loconfig.tasks.scripts.forEach(async ({ + includes, + outdir = '', + outfile = '' + }) => { + const filename = basename(outdir || outfile || 'undefined'); const timeLabel = `${filename} compiled in`; console.time(timeLabel); - esbuild.build({ - entryPoints: includes, - bundle: true, - minify: true, - sourcemap: true, - color: true, - logLevel: 'error', - target: [ - 'es2015', - ], - outfile - }).catch((err) => { + try { + includes = includes.map((path) => template(path)); + + if (outdir) { + outdir = template(outdir); + } else if (outfile) { + outfile = template(outfile); + } else { + throw new TypeError( + 'Expected \'outdir\' or \'outfile\'' + ); + } + + await esbuild.build({ + entryPoints: includes, + bundle: true, + minify: true, + sourcemap: true, + color: true, + logLevel: 'error', + target: [ + 'es2015', + ], + outdir, + outfile + }); + + message(`${filename} compiled`, 'success', timeLabel); + } catch (err) { // errors managments (already done in esbuild) notification({ title: `${filename} compilation failed 🚨`, message: `${err.errors[0].text} in ${err.errors[0].location.file} line ${err.errors[0].location.line}` }); - }).then(() => { - message(`${filename} compiled`, 'success', timeLabel); - }); + } }); }; diff --git a/build/tasks/styles.js b/build/tasks/styles.js index f5b2b54..a30c99d 100644 --- a/build/tasks/styles.js +++ b/build/tasks/styles.js @@ -1,8 +1,10 @@ -import paths from '../mconfig.json'; +import loconfig from '../../loconfig.json'; import message from '../utils/message.js'; -import postcss from '../utils/postcss.js'; import notification from '../utils/notification.js'; +import postcss from '../utils/postcss.js'; +import template from '../utils/template.js'; import { writeFile } from 'node:fs/promises'; +import { basename } from 'node:path'; import { promisify } from 'node:util'; import sass from 'node-sass'; @@ -12,17 +14,19 @@ const sassRender = promisify(sass.render); * Compiles and minifies main Sass files to CSS. */ export async function compileStyles() { - [ - 'critical', - 'main', - ].forEach(async (name) => { - const infile = paths.styles.src + name + '.scss'; - const outfile = paths.styles.dest + name + '.css'; + loconfig.tasks.styles.forEach(async ({ + infile, + outfile + }) => { + const name = basename((outfile || 'undefined'), '.scss'); const timeLabel = `${name}.css compiled in`; console.time(timeLabel); try { + infile = template(infile); + outfile = template(outfile); + let result = await sassRender({ file: infile, omitSourceMapUrl: true, diff --git a/build/tasks/svgs.js b/build/tasks/svgs.js index fe563ea..c9e1722 100644 --- a/build/tasks/svgs.js +++ b/build/tasks/svgs.js @@ -1,35 +1,37 @@ -import paths from '../mconfig.json'; +import loconfig from '../../loconfig.json'; import message from '../utils/message.js'; import notification from '../utils/notification.js'; +import template from '../utils/template.js'; +import { basename } from 'node:path'; import mixer from 'svg-mixer'; /** * Generates and transforms SVG spritesheets. */ export async function compileSVGs() { - [ - { - includes: [ paths.svgs.src + '*.svg' ], - filename: 'sprite.svg' - }, - ].forEach(({ + loconfig.tasks.svgs.forEach(async ({ includes, - filename + outfile }) => { - const outfile = paths.scripts.dest + filename; + const filename = basename(outfile || 'undefined'); const timeLabel = `${filename} compiled in`; console.time(timeLabel); - mixer(includes, { - spriteConfig: { - usages: false - } - }).then((result) => { - result.write(outfile).then(() => { - message(`${filename} compiled`, 'success', timeLabel); + try { + includes = includes.map((path) => template(path)); + outfile = template(outfile); + + const result = await mixer(includes, { + spriteConfig: { + usages: false + } }); - }).catch((err) => { + + await result.write(outfile); + + message(`${filename} compiled`, 'success', timeLabel); + } catch (err) { message(`Error compiling ${filename}`, 'error'); message(err); @@ -37,6 +39,6 @@ export async function compileSVGs() { title: `${filename} compilation failed 🚨`, message: `${err.name}: ${err.message}` }); - }); + } }); }; diff --git a/build/utils/glob.js b/build/utils/glob.js new file mode 100644 index 0000000..8d09c3f --- /dev/null +++ b/build/utils/glob.js @@ -0,0 +1,75 @@ +/** + * @file Retrieve the first available glob library. + * + * Note that options vary between libraries. + * + * Candidates: + * - {@link https://npmjs.com/package/tiny-glob tiny-glob} + * - {@link https://npmjs.com/package/globby} + * - {@link https://npmjs.com/package/fast-glob fast-glob} + * - {@link https://npmjs.com/package/glob glob} + */ + +import { promisify } from 'node:util'; + +const modules = [ + 'tiny-glob', + 'globby', + 'fast-glob', + 'glob', +]; + +var glob; + +for (let name of modules) { + try { + glob = await import(name); + glob = glob.default; + + /** + * Wrap the function to ensure + * a common pattern. + */ + switch (name) { + case 'tiny-glob': + glob = createArrayableGlob(glob, { + filesOnly: true + }); + break; + + case 'glob': + glob = promisify(glob); + break; + } + + break; // loop + } catch (err) { + // swallow this error; skip to the next candidate. + } +} + +if (!glob) { + throw new TypeError( + `No glob library was found, expected one of: ${modules.join(', ')}` + ); +} + +export default glob; + +/** + * Creates a wrapper function for the glob function + * to provide support for arrays of patterns. + * + * @param {function} glob - The glob function. + * @param {object} options - The glob options. + * @return {Promise} + */ +function createArrayableGlob(glob, options) { + return (patterns, options) => { + const globs = patterns.map((pattern) => glob(pattern, options)); + + return Promise.all(globs).then((files) => { + return [].concat.apply([], files); + }); + }; +} diff --git a/build/utils/template.js b/build/utils/template.js new file mode 100644 index 0000000..2d9b633 --- /dev/null +++ b/build/utils/template.js @@ -0,0 +1,111 @@ +/** + * @file Provides simple template tags. + */ + +import loconfig from '../../loconfig.json'; + +const templateData = flatten({ + paths: loconfig.paths +}); + +/** + * Replaces all template tags from a map of keys and values. + * + * If replacement pairs contain a mix of substrings, regular expressions, + * and functions, regular expressions are executed last. + * + * @param {string} input - The string being searched and replaced on. + * @param {object} data - An object in the form `{ 'from': 'to', … }`. + * @return {string} Returns the translated string. + */ +export default function template(input, data) { + const tags = []; + + if (data) { + data = flatten(data); + } else { + data = templateData; + } + + for (let tag in data) { + tags.push(escapeRegExp(tag)); + } + + if (tags.length === 0) { + return input; + } + + const search = new RegExp('\\{%\\s*(' + tags.join('|') + ')\\s*%\\}', 'g'); + return input.replace(search, (match, key) => { + let value = data[key]; + + switch (typeof value) { + case 'function': + /** + * Retrieve the offset of the matched substring `args[0]` + * and the whole string being examined `args[1]`. + */ + let args = Array.prototype.slice.call(arguments, -2); + return value.call(data, match, args[0], args[1]); + + case 'string': + case 'number': + return value; + } + + return ''; + }); +}; + +/** + * Creates a new object with all nested object properties + * concatenated into it recursively. + * + * Nested keys are flattened into a property path: + * + * ```js + * { + * a: { + * b: { + * c: 1 + * } + * }, + * d: 1 + * } + * ``` + * + * ```js + * { + * "a.b.c": 1, + * "d": 1 + * } + * ``` + * + * @param {object} input - The object to flatten. + * @param {string} prefix - The parent key prefix. + * @param {object} target - The object that will receive the flattened properties. + * @return {object} Returns the `target` object. + */ +function flatten(input, prefix, target = {}) { + for (let key in input) { + let field = (prefix ? prefix + '.' + key : key); + + if (typeof input[key] === 'object') { + flatten(input[key], field, target); + } else { + target[field] = input[key]; + } + } + + return target; +} + +/** + * Quotes regular expression characters. + * + * @param {string} str - The input string. + * @return {string} Returns the quoted (escaped) string. + */ +function escapeRegExp(str) { + return str.replace(/[\[\]\{\}\(\)\-\*\+\?\.\,\\\^\$\|\#\s]/g, '\\$&'); +} diff --git a/build/watch.js b/build/watch.js index e9f29b0..a060be8 100644 --- a/build/watch.js +++ b/build/watch.js @@ -1,10 +1,13 @@ -import paths from '../mconfig.json'; -import { concatVendors } from './tasks/concat.js'; +import loconfig from '../loconfig.json'; +import { concatFiles } from './tasks/concats.js'; import { compileScripts } from './tasks/scripts.js'; import { compileStyles } from './tasks/styles.js' ; import { compileSVGs } from './tasks/svgs.js'; +import template from './utils/template.js'; import server from 'browser-sync'; +const { paths, tasks } = loconfig; + const serverConfig = { open: false, notify: false @@ -23,8 +26,9 @@ if (typeof paths.url === 'string' && paths.url.length > 0) { // Start the Browsersync server server.init(serverConfig); -// Build scripts, compile styles, concat vendors and generate the svgs sprite on first hit -concatVendors(); +// Build scripts, compile styles, concat files, +// and generate spritesheets on first hit +concatFiles(); compileScripts(); compileStyles(); compileSVGs(); @@ -48,13 +52,14 @@ server.watch( compileScripts(); }); -// Watch scripts vendors +// Watch concats server.watch( - [ - paths.scripts.vendors.src + '*.js', - ] + tasks.concats.reduce( + (patterns, { includes }) => patterns.concat(includes), + [] + ).map((path) => template(path)) ).on('change', () => { - concatVendors(); + concatFiles(); }); // Watch styles diff --git a/loconfig.json b/loconfig.json new file mode 100755 index 0000000..e6fe378 --- /dev/null +++ b/loconfig.json @@ -0,0 +1,61 @@ +{ + "paths": { + "url": "locomotive-boilerplate.test", + "src": "./assets", + "dest": "./www", + "images": { + "src": "./assets/images" + }, + "styles": { + "src": "./assets/styles", + "dest": "./www/assets/styles" + }, + "scripts": { + "src": "./assets/scripts", + "dest": "./www/assets/scripts" + }, + "svgs": { + "src": "./assets/images/sprite", + "dest": "./www/assets/images" + }, + "views": { + "src": "./views/boilerplate/template" + } + }, + "tasks": { + "concats": [ + { + "includes": [ + "{% paths.scripts.src %}/vendors/*.js" + ], + "outfile": "{% paths.scripts.dest %}/vendors.js" + } + ], + "scripts": [ + { + "includes": [ + "{% paths.scripts.src %}/app.js" + ], + "outfile": "{% paths.scripts.dest %}/app.js" + } + ], + "styles": [ + { + "infile": "{% paths.styles.src %}/critical.scss", + "outfile": "{% paths.styles.dest %}/critical.css" + }, + { + "infile": "{% paths.styles.src %}/main.scss", + "outfile": "{% paths.styles.dest %}/main.css" + } + ], + "svgs": [ + { + "includes": [ + "{% paths.svgs.src %}/*.svg" + ], + "outfile": "{% paths.svgs.dest %}/sprite.svg" + } + ] + } +} diff --git a/mconfig.json b/mconfig.json deleted file mode 100755 index 7fa8832..0000000 --- a/mconfig.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "url": "locomotive-boilerplate.test", - "src": "./assets/", - "dest": "./www/", - "build": "./build/", - "styles": { - "src": "./assets/styles/", - "dest": "./www/assets/styles/" - }, - "scripts": { - "src": "./assets/scripts/", - "dest": "./www/assets/scripts/", - "vendors": { - "src": "./assets/scripts/vendors/" - } - }, - "svgs": { - "src": "./assets/images/sprite/", - "dest": "./www/assets/images/" - }, - "views": { - "src": "./views/boilerplate/template/" - } -} diff --git a/package-lock.json b/package-lock.json index 0895ced..060245d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,7 +23,8 @@ "node-notifier": "^10.0.0", "node-sass": "^6.0.1", "postcss": "^8.3.6", - "svg-mixer": "^2.3.14" + "svg-mixer": "^2.3.14", + "tiny-glob": "^0.2.9" }, "engines": { "node": ">=14.17" @@ -2505,6 +2506,18 @@ "node": ">=6" } }, + "node_modules/globalyzer": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/globalyzer/-/globalyzer-0.1.0.tgz", + "integrity": "sha512-40oNTM9UfG6aBmuKxk/giHn5nQ8RVz/SS4Ir6zgzOv9/qC3kKZ9v4etGTcJbEl/NyVQH7FGU7d+X1egr57Md2Q==", + "dev": true + }, + "node_modules/globrex": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/globrex/-/globrex-0.1.2.tgz", + "integrity": "sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==", + "dev": true + }, "node_modules/globule": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/globule/-/globule-1.3.2.tgz", @@ -6133,6 +6146,16 @@ "resolved": "https://registry.npmjs.org/tiny-emitter/-/tiny-emitter-1.2.0.tgz", "integrity": "sha1-bchFBSywjr78GHRyO1jySmSMO28=" }, + "node_modules/tiny-glob": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/tiny-glob/-/tiny-glob-0.2.9.tgz", + "integrity": "sha512-g/55ssRPUjShh+xkfx9UPDXqhckHEsHr4Vd9zX55oSdGZc/MD0m3sferOkwWtp98bv+kcVfEHtRJgBVJzelrzg==", + "dev": true, + "dependencies": { + "globalyzer": "0.1.0", + "globrex": "^0.1.2" + } + }, "node_modules/to-array": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/to-array/-/to-array-0.1.4.tgz", @@ -8545,6 +8568,18 @@ } } }, + "globalyzer": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/globalyzer/-/globalyzer-0.1.0.tgz", + "integrity": "sha512-40oNTM9UfG6aBmuKxk/giHn5nQ8RVz/SS4Ir6zgzOv9/qC3kKZ9v4etGTcJbEl/NyVQH7FGU7d+X1egr57Md2Q==", + "dev": true + }, + "globrex": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/globrex/-/globrex-0.1.2.tgz", + "integrity": "sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==", + "dev": true + }, "globule": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/globule/-/globule-1.3.2.tgz", @@ -11506,6 +11541,16 @@ "resolved": "https://registry.npmjs.org/tiny-emitter/-/tiny-emitter-1.2.0.tgz", "integrity": "sha1-bchFBSywjr78GHRyO1jySmSMO28=" }, + "tiny-glob": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/tiny-glob/-/tiny-glob-0.2.9.tgz", + "integrity": "sha512-g/55ssRPUjShh+xkfx9UPDXqhckHEsHr4Vd9zX55oSdGZc/MD0m3sferOkwWtp98bv+kcVfEHtRJgBVJzelrzg==", + "dev": true, + "requires": { + "globalyzer": "0.1.0", + "globrex": "^0.1.2" + } + }, "to-array": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/to-array/-/to-array-0.1.4.tgz", diff --git a/package.json b/package.json index 8563bd3..8aab041 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "node-notifier": "^10.0.0", "node-sass": "^6.0.1", "postcss": "^8.3.6", - "svg-mixer": "^2.3.14" + "svg-mixer": "^2.3.14", + "tiny-glob": "^0.2.9" } }