UNPKG

architekt

Version:

A Dead Simple Static Site Generator Powered by Handlebars

318 lines (291 loc) 13.9 kB
const Promise = require('bluebird') const hbs = require('handlebars') const layouts = require('handlebars-layouts') const log = require('ulog')('architekt:cmd:render') const sass = require('node-sass') const ncp = require('ncp').ncp const del = require('del') const fs = require('fs') const path = require('path') const { Config } = require('../config') // Limit depth of recursive directory copying ncp.limit = 16 // Async functions are neat const [readdir, readFile, writeFile, unlink, rmdir, renderSass] = [ fs.readdir, fs.readFile, fs.writeFile, fs.unlink, fs.rmdir, sass.render ].map(func => Promise.promisify(func)) hbs.registerHelper(layouts(hbs)) /** * @param {Config} config The config object to use when rendering * * @returns {Promise<void>} */ module.exports = function render(config) { const OUT_DIR_PATH = config.pathTo('outDir') // Location of handlebars templates const TEMPLATE_SOURCE_DIR = config.pathTo('templateDir') // Location of JSON data const TEMPLATE_DATA_DIR = config.pathTo('controllerDir') const LAYOUT_DIR = config.pathTo('layoutDir') const HELPER_DIR = config.pathTo('helperDir') const ASSETS_DIR = config.pathTo('assetDir') // Location of directories containing partials. // Elements of this array can be strings or string arrays. // When a string array, each element represents a subdirectory. const TEMPLATE_PARTIALS_DIRS = config.pathTo('partialDirs') log.log(`Template path: ${TEMPLATE_SOURCE_DIR}`) log.log(`Data path: ${TEMPLATE_DATA_DIR}`) log.log(`Output path: ${OUT_DIR_PATH}\n`) /** * Reads and parses handlebars templates from the template directory. * * The promise resolves with the following shape * ```js * { * index: "<index.html.hbs file contents>", * about: "<about.html.hbs file contents>", * // etc * } * ``` * * @returns {Promise<any>} Map of parsed handlebars templates */ const sources_async = () => Promise.try(() => log.log('Loading templates...')) .then(() => readdir(TEMPLATE_SOURCE_DIR, 'utf-8')) .filter(f => { let split_file = f.split('.') // templates must end in .hbs. Also, ignore partials, which start with '_' return split_file[split_file.length - 1] === 'hbs' && !split_file[0].startsWith('_') }) .map(f => { // Read the template return readFile(path.join(TEMPLATE_SOURCE_DIR, f), 'utf-8') .then(contents => { return { page: f.split('.')[0], value: contents } }) }) .reduce((source_map, source) => { source_map[source.page] = source.value return source_map }, Object.create(null)) .catch(raise('Error thrown while reading templates')); /* * { * "index": {...data...}, * "about": {...data...}, * } */ // Read in the JSON files containing each page's data const data_async = () => Promise.try(() => log.log('Loading template data...')) .then(() => readdir(TEMPLATE_DATA_DIR, 'utf-8')) .filter(file => { /** @type {string[]} */ let split_name = file.split('.') let filename = split_name[0] let filetype = split_name[split_name.length - 1] // Ignore files that start with an underscore let is_partial = filename.startsWith('_') if (is_partial) { log.debug(`data: skipping controller partial ${filename}.`) return false } // data files must end in .json or .js let valid = ['json', 'js'].includes(filetype) log.debug(`data: controller file ${filename} ends with ${filetype} is ${valid ? 'valid' : 'invalid'}`) return valid }) .map(async d => { // get the raw data from the file let data_path = path.join(TEMPLATE_DATA_DIR, d) if (d.endsWith('.json')) { // Controller is a JSON object, parse it, add it to map. return readFile(data_path, 'utf-8') .then(contents => { // parse the data into an object return JSON.parse(contents) }).catch(err => { // error thrown when reading file, default to empty object log.error('Error thrown while reading JSON file; using empty object') log.warn(err) return {} }) .then(data_obj => { return { page: d.split('.')[0], value: data_obj } }) } else if (d.endsWith('.js')) { // data is a script that exports a controller object let data_exports = await require(data_path) let controller = data_exports.default || data_exports // handle ES6 exports return { page: d.split('.')[0], value: controller } } else { // should be unreachable, acts as a sanity check throw new Error('Invalid data filetype.') } }) .reduce((data_map, data) => { data_map[data.page] = data.value return data_map }, Object.create(null)) .catch(raise('Error thrown while parsing template data')) /* * { * "index": "...html...", * "about": "...html...", * } */ /** * Register handlebars partials */ const partials_async = () => Promise.each(TEMPLATE_PARTIALS_DIRS, dir => { return readdir(dir, 'utf-8') // Get the files in one of the partial directories // Filter out anything that isn't a handlebars file or doesn't start with '_' .filter(partial => partial.startsWith('_') && partial.endsWith('.hbs')) .each(partial => { return readFile(path.join(dir, partial), 'utf-8') .then(partial_contents => { // Read the contents and register it with handlebars let partial_name = partial.split('.')[0] partial_name = partial_name.substr(1, partial_name.length - 1) log.log(`Registering partial ${partial_name}...`) partial_contents.page_name = partial_contents.page_name || partial_name return hbs.registerPartial(partial_name, partial_contents) }) }) }) .catch(raise('Error thrown while registering partials')) const layouts_async = () => Promise.try(() => log.log('Loading layouts...')) .then(() => readdir(LAYOUT_DIR, 'utf-8')) .filter(layout_file => layout_file.endsWith('.hbs')) .each(layout_file => { readFile(path.join(LAYOUT_DIR, layout_file), 'utf-8') .then(layout_contents => { let layout_name = layout_file.split('.')[0] log.log(`Registering layout ${layout_name}...`) return hbs.registerPartial(layout_name, layout_contents) }) }) .catch(raise('Error thrown while registering layouts')) const helpers_async = () => Promise.try(() => log.log('Registering helpers...')) .then(() => readdir(HELPER_DIR, 'utf-8')) .filter(helper_file => helper_file.endsWith('.js')) .each(async helper_file => { let helpers = await require(path.join(HELPER_DIR, helper_file)) for (let helper in helpers) { let fn = helpers[helper] if (typeof fn === 'function') { log.log(`Registering helper ${helper}...`) hbs.registerHelper(helper, fn) } } }) .catch(raise('Error thrown while registering helpers')) // TODO: Implement this correctly and add it to the join() /* const styles_async = () => new Promise((resolve, reject) => { log.info('Rendering stylesheets...') // path to styles directory in source let styles_dir = path.join(config.pathTo('assetDir'), 'styles') // path to styles directory in build let build_styles_dir = path.join(config.pathTo('outDir'), config.resources.assetDir, 'styles') // remove trailing separator character(s) if (assetDir.endsWith(path.sep)) assetDir = assetDir.substr(0, assetDir.length - path.sep.length) }) */ /** * Parses and renders the SASS stylesheets in $assetDir/stylesheets. * * @returns {{name: string, contents: string}[]} an object containing the * stylesheet's name and the rendered contents of the stylesheet. */ const styles_async = () => Promise.try(() => log.log('Loading stylesheets...')) .then(() => readdir(path.join(ASSETS_DIR, 'stylesheets'), { encoding: 'utf-8', withFileTypes: true })) .filter(file => { // file is an instance of fs.Dirent if (!file.isFile()) return false // File cannot start with an underscore and must end with ."scss" or ."sass" return file.name.match(/^[^_].*\.(?:scss|sass)$/) }) .map(async stylesheet => { // stylesheet is an instance of fs.Dirent // Path to the stylesheet let stylesheet_path = path.join(ASSETS_DIR, 'stylesheets', stylesheet.name) let compiled_stylesheet = await renderSass({ file: stylesheet_path, includePaths: [ path.join(ASSETS_DIR, 'stylesheets'), path.join(config.root, 'node_modules') ] }) return { name: stylesheet.name, contents: compiled_stylesheet.css } }) .catch(raise('Error thrown while compiling stylesheets')) /** * Recursively copies over the assets directory to the output directory, * then calls `styles_async()` to compile the SASS stylesheets. */ const assets_async = () => Promise.try(() => log.log('Copying assets...')) .then(() => { return new Promise((resolve, reject) => { // TODO: Promisify this function ncp( ASSETS_DIR, // Copying asset sub dir in source dir path.join(OUT_DIR_PATH, config.resources.assetDir), // Copying to output dir { filter: /^((?!stylesheets).)*$/ }, // Don't copy over stylesheets, styles_async handles this err => { if (err) reject(err) else resolve() }); }) }) .catch(raise('Error thrown while copying src asset dir to output asset dir.')) .then(() => fs.promises.mkdir(path.join(OUT_DIR_PATH, config.resources.assetDir, 'stylesheets'))) .catch(raise('Error thrown while creating output stylesheet directory')) .then(styles_async) // const pages = [] return del([path.join(OUT_DIR_PATH, '**'), `!${OUT_DIR_PATH}`]) .catch(raise('Error thrown while cleaning output directory.')) .then(() => { return Promise.join( sources_async(), // Read in and parse page views data_async(), // Read in and parse page controllers assets_async(), // Read in and compile SASS stylesheets partials_async(), // Read in and register Handlebars partials layouts_async(), // Read in and register layouts (fancy Handlebars partials) helpers_async(), // Read in and register Handlebars helper functions async (sources, data, stylesheets) => { log.log('Rendering templates...') log.debug(`Partials: ${JSON.stringify(Object.keys(hbs.partials))}`) log.debug(`Pages: ${Object.keys(sources)}`) log.debug(`Helpers: ${JSON.stringify(hbs.helpers)}`) // Write compiled pages to output directory for (let page_name in sources) { let template = hbs.compile(sources[page_name]) let page; try { // Error thrown if template contains a syntax error page = template(data[page_name] || {}) } catch (err) { log.error(`Syntax error in page ${page_name}:\n${err.message}`) log.debug(err.stack) process.exit(1) } let page_path = path.join(OUT_DIR_PATH, page_name + '.html') log.log(`Rendering ${page_name}...`) // pages.push(await writeFile(page_path, page)) await writeFile(page_path, page).then(() => log.log('Finished')) } // Write compiled stylesheets to output directory for await (let stylesheet of stylesheets) { let { name, contents } = stylesheet; name = name.replace(/\.(?:scss|sass)/, '.css') let stylesheet_out_path = path.join(OUT_DIR_PATH, config.resources.assetDir, 'stylesheets', name) await writeFile(stylesheet_out_path, contents) .then(() => log.log(`Saved ${name} to ${stylesheet_out_path}.`)) .catch(raise(`Error thrown while writing stylesheet "${name}"`)) } }) .then(() => log.info('Finished rendering.')) }) }; function raise(msg) { return err => { log.error(msg) log.error(err) } }