@bolt/build-tools
Version:
Curated collection of front-end build tools in the Bolt Design System.
291 lines (256 loc) • 7.97 kB
JavaScript
const { render } = require('@bolt/twig-renderer');
const chalk = require('chalk');
const path = require('path');
const { promisify } = require('util');
const fs = require('fs');
const mkdirp = promisify(require('mkdirp'));
const readFile = promisify(fs.readFile);
const readdir = promisify(fs.readdir);
const writeFile = promisify(fs.writeFile);
const lstat = promisify(fs.lstat);
const chokidar = require('chokidar');
const del = require('del');
const globby = require('globby');
const debounce = require('lodash.debounce');
const fm = require('front-matter');
const ora = require('ora');
const marked = require('marked');
const timer = require('@bolt/build-utils/timer');
const manifest = require('@bolt/build-utils/manifest');
const { getConfig } = require('@bolt/build-utils/config-store');
const log = require('@bolt/build-utils/log');
const events = require('@bolt/build-utils/events');
const sh = require('@bolt/build-utils/sh');
let config;
async function asyncConfig() {
if (config) {
return config;
} else {
config = Object.assign(
{
watchedExtensions: ['twig', 'md', 'html', 'yml'],
},
await getConfig(),
);
return config;
}
}
/**
* Prep a JSON string for use in bash
* @param {string} string
* @returns {string}
*/
function escapeNestedSingleQuotes(string) {
return string.replace(/'/g, "'\\''");
}
/**
* Get data for a single page that is Markdown or HTML with Yaml front matter
* @param {string} file - Path to source file
* @returns {Promise<{srcPath: string, distPath: string, meta: object, body: string}>} page - Page Data
*/
async function getPage(file) {
config = config || (await asyncConfig());
if (config.verbosity > 3) {
log.dim(`Getting info for: ${file}`);
}
const url = path
.relative(config.srcDir, file)
.replace('.md', '.html')
.split('/')
.map(x => x.replace(/^[0-9]*-/, '')) // Removing number prefix `05-item` => `item`
.join('/');
const fileContents = await readFile(file, 'utf8');
// https://www.npmjs.com/package/front-matter
const { attributes, body, frontmatter } = fm(fileContents);
const dirTree = url.split('/');
let depth = url.split('/').filter(x => x !== 'index.html').length;
let parent = dirTree[depth - 2];
// Don't do it for homepage
if (url === 'index.html') depth = 1;
const page = {
srcPath: file,
url,
depth,
parent,
meta: attributes,
body: file.endsWith('.md') ? marked(body) : body,
};
return page;
}
/**
* Get data for all pages
* @param {string} files - Source directory
* @see getPage
* @returns {Promise<object[]>} - An array of page data objects
*/
async function getPages(srcDir) {
config = config || (await asyncConfig());
/** @type Array<String> */
const allPaths = await globby([
path.join(srcDir, '**/*.{md,html}'),
'!**/_*/**/*.{md,html}',
'!**/pattern-lab/**/*',
'!**/_*.{md,html}',
]);
return Promise.all(allPaths.map(getPage)).then(pages => {
if (config.verbosity > 4) {
log.dim('All data for Static pages:');
console.log(pages);
log.dim('END: All data for Static pages.');
}
return pages;
});
}
/**
* Recursively crawl a directory and subdirectory to build a nested content object.
* Returns an object that reflects directory structure and contains html and
* markdown file information generated by getPage().
*
* How this function works:
* If the folder passed in is for a directory, it will run `getPage()` for the "index" file inside,
* then also get info on all children all contents of that directory.
*
* @param {string} folder A system path to a directory
* @returns {Promise<object[]>}
*/
async function getNestedPages(folder) {
config = config || (await asyncConfig());
const items = await globby(['*', '!_*', '!pattern-lab'], {
cwd: folder,
onlyFiles: false,
});
return Promise.all(
items.map(async item => {
const fullPath = path.join(folder, item);
const stats = await lstat(fullPath);
if (stats.isDirectory()) {
const indexFile = path.join(fullPath, '00-index.md'); // @todo Make this work with `00-index.html`, `01-index.md`, `index.md`, or `index.html`
const children = await getNestedPages(fullPath);
// The children include the `indexFile` as well, so let's remove it.
const filterChildren = children.filter(
child => indexFile !== child.srcPath,
);
let item;
try {
item = await getPage(indexFile);
} catch (error) {
log.error(
`Each folder in static site content must contain a file called "00-index.md", please make one here: ${indexFile}`,
);
process.exit(1); // exiting immediately so follow up error messages don't confuse user.
}
item.children = filterChildren;
return item;
} else {
return await getPage(fullPath);
}
}),
);
}
/**
* Get the site data based on the pages
* @param {object} pages
* @returns {{pages}}
*/
async function getSiteData(pages) {
config = config || (await asyncConfig());
const nestedPages = await getNestedPages(config.srcDir);
const site = {
nestedPages,
pages: pages.map(page => ({
url: page.url,
meta: page.meta,
// choosing not to have `page.body` in here on purpose
})),
};
return site;
}
/**
* The main event - compile the whole site
* @returns {Promise<any[]>}
*/
async function compile(exitOnError = true) {
config = config || (await asyncConfig());
const startMessage = chalk.blue('Compiling Static Site...');
const startTime = timer.start();
let spinner;
if (config.verbosity > 2) {
console.log(startMessage);
} else {
spinner = ora(startMessage).start();
}
const pages = await getPages(config.srcDir);
const renderPages = pages.map(async page => {
const site = await getSiteData(pages);
const layout = page.meta.layout ? page.meta.layout : 'default';
const { ok, html, message } = await render(`@bolt/${layout}.twig`, {
page,
site,
});
if (!ok) {
if (exitOnError) {
log.errorAndExit(message);
} else {
log.error(message);
}
}
const htmlFilePath = path.join(config.wwwDir, page.url);
await mkdirp(path.dirname(htmlFilePath));
await writeFile(htmlFilePath, html);
if (config.verbosity > 3) {
log.dim(`Wrote: ${htmlFilePath}`);
}
return true;
});
Promise.all(renderPages)
.then(() => {
const endMessage = chalk.green(
`Compiled Static Site in ${timer.end(startTime)}`,
);
if (config.verbosity > 2) {
console.log(endMessage);
} else {
spinner.succeed(endMessage);
}
})
.catch(error => {
console.log(error);
const endMessage = chalk.red(
`Compiling Static Site failed in ${timer.end(startTime)}`,
);
spinner.fail(endMessage);
});
}
function compileWithNoExit() {
return compile(false);
}
async function watch() {
config = Object.assign(
{
watchedExtensions: ['.twig', '.md', '.html', '.yml'],
},
await getConfig(),
);
const watchedPaths = [];
// generate wwwDir globbed paths for each file extension being watched
config.watchedExtensions.forEach(ext => {
watchedPaths.push(path.join(process.cwd(), '**/*' + ext));
});
// The watch event ~ same engine gulp uses https://www.npmjs.com/package/chokidar
const watcher = chokidar.watch(watchedPaths, {
ignoreInitial: true,
cwd: process.cwd(),
ignored: ['**/node_modules/**', '**/vendor/**', '**/_patterns/**'],
});
// list of all events: https://www.npmjs.com/package/chokidar#methods--events
watcher.on('all', (event, path) => {
if (config.verbosity > 3) {
console.log('Static Site watch event: ', event, path);
}
compileWithNoExit();
});
}
module.exports = {
compile,
watch,
};