UNPKG

kss

Version:

The Node.js port of KSS: A methodology for documenting CSS and building style guides

1,251 lines (1,141 loc) 45 kB
'use strict'; /** * The `kss/builder/base` module loads the {@link KssBuilderBase} class. * ``` * const KssBuilderBase = require('kss/builder/base'); * ``` * @module kss/builder/base */ /* *************************************************************** See kss_builder_base_example.js for how to implement a builder. *************************************************************** */ const md = require('../../lib/md.js'); const path = require('path'); const Promise = require('bluebird'); const resolve = require('resolve'); // replace by require.resolve for node >= 8.9 const fs = Promise.promisifyAll(require('fs-extra')), glob = Promise.promisify(require('glob')), kssBuilderAPI = '3.0'; /** * A kss-node builder takes input files and builds a style guide. */ class KssBuilderBase { /** * Create a KssBuilderBase object. * * This is the base object used by all kss-node builders. * * ``` * const KssBuilderBase = require('kss/builder/base'); * class KssBuilderCustom extends KssBuilderBase { * // Override methods of KssBuilderBase. * } * ``` */ constructor() { this.optionDefinitions = {}; this.options = {}; // Store the version of the builder API that the builder instance is // expecting; we will verify this in loadBuilder(). this.API = 'undefined'; // The log function defaults to console.log. this.setLogFunction(console.log); // The error logging function defaults to console.error. this.setLogErrorFunction(console.error); // Tell kss-node which Yargs-like options this builder has. this.addOptionDefinitions({ 'source': { group: 'File locations:', string: true, path: true, describe: 'Source directory or wildcard to recursively parse for KSS comments, homepage, and markup' }, 'base': { group: 'File locations:', string: true, multiple: false, describe: 'Base directory, used to resolve sources, homepage, etc.', default: process.cwd() }, 'destination': { group: 'File locations:', string: true, path: true, multiple: false, describe: 'Destination directory of style guide', default: 'styleguide' }, 'json': { group: 'File locations:', boolean: true, multiple: false, describe: 'Output a JSON object instead of building a style guide' }, 'mask': { group: 'File locations:', alias: 'm', string: true, multiple: false, describe: 'Use a mask for detecting files containing KSS comments', default: '*.css|*.less|*.sass|*.scss|*.styl|*.stylus' }, 'clone': { group: 'Builder:', string: true, path: true, multiple: false, describe: 'Clone a style guide builder to customize' }, 'builder': { group: 'Builder:', alias: 'b', string: true, path: true, multiple: false, describe: 'Use the specified builder when building your style guide', default: path.join('builder', 'handlebars') }, 'css': { group: 'Style guide:', string: true, describe: 'URL of a CSS file to include in the style guide' }, 'js': { group: 'Style guide:', string: true, describe: 'URL of a JavaScript file to include in the style guide' }, 'custom': { group: 'Style guide:', string: true, describe: 'Process a custom property name when parsing KSS comments' }, 'extend': { group: 'Style guide:', string: true, path: true, describe: 'Location of modules to extend the templating system; see http://bit.ly/kss-wiki' }, 'homepage': { group: 'Style guide:', string: true, multiple: false, describe: 'File name of the homepage’s Markdown file related to working directory', default: path.join(process.cwd(), 'homepage.md') }, 'markup': { group: 'Style guide:', boolean: true, multiple: false, describe: 'Render "markup" templates to HTML with the placeholder text', default: false }, 'placeholder': { group: 'Style guide:', string: true, multiple: false, describe: 'Placeholder text to use for modifier classes', default: '[modifier class]' }, 'nav-depth': { group: 'Style guide:', multiple: false, describe: 'Limit the navigation to the depth specified', default: 3 }, 'verbose': { count: true, multiple: false, describe: 'Display verbose details while building' } }); } /** * Resolve the builder path from the given file path. * * Call this static method to resolve the builder path. * * @param {string} builder The path to a builder or a builder * to load. * @returns {string} resolved path */ static builderResolve(builder) { const cmdDir = process.cwd(); const kssDir = path.resolve(__dirname, '../..'); const pathsToResolve = [ cmdDir, // looking from commande path path.resolve(cmdDir, 'node_modules'), // looking for external module kssDir, // kss native builder path.resolve(kssDir, 'node_modules') // old npm version ]; let resolvedPath = builder; try { resolvedPath = path.dirname(resolve.sync(builder, {paths: pathsToResolve})); } catch (e) { // console.log(`Your builder path "${builder}" is maybe wrong.`); } return resolvedPath; } /** * Loads the builder from the given file path or class. * * Call this static method to load the builder and verify the builder * implements the correct builder API version. * * @param {string|function} builderClass The path to a builder or a builder * class to load. * @returns {Promise.<KssBuilderBase>} A `Promise` object resolving to a * `KssBuilderBase` object, or one of its sub-classes. */ static loadBuilder(builderClass) { return new Promise((resolve, reject) => { let newBuilder = {}, SomeBuilder, isCompatible = true, builderAPI = 'undefined'; try { // The parameter can be a class or constructor function. if (typeof builderClass === 'function') { SomeBuilder = builderClass; // If the parameter is a path, try to load the module. } else if (typeof builderClass === 'string') { SomeBuilder = require(builderClass); // Unexpected parameter. } else { return reject(new Error('Unexpected value for "builder"; should be a path to a module or a JavaScript Class.')); } // Check for a kss-node 2.0 template and KssGenerator. Template's were // objects that provided the builder (generator) as a property. if (typeof SomeBuilder === 'object' && SomeBuilder.hasOwnProperty('generator') && SomeBuilder.generator.hasOwnProperty('implementsAPI')) { isCompatible = false; builderAPI = SomeBuilder.generator.implementsAPI; // Try to create a new builder. } else { newBuilder = new SomeBuilder(); } } catch (e) { // Builders don’t have to export their own builder class. If the builder // fails to export a builder class, we assume it wanted the default // builder. If the loader fails when given a string, we check if the // caller (either cli.js or kss.js) wanted the Twig builder and let the // caller recover from the thrown error. const supportedBuilders = [ 'builder/twig', 'builder/nunjucks' ]; // istanbul ignore if if (supportedBuilders.indexOf(builderClass) > -1) { return reject(new Error(`The specified builder, "${builderClass}", is not relative to the current working directory.`)); } else { let KssBuilderHandlebars = require('../handlebars'); newBuilder = new KssBuilderHandlebars(); } } // Grab the builder API version. if (newBuilder.hasOwnProperty('API')) { builderAPI = newBuilder.API; } // Ensure KssBuilderBase is the base class. if (!(newBuilder instanceof KssBuilderBase)) { isCompatible = false; } else if (builderAPI.indexOf('.') === -1) { isCompatible = false; } else { let version = kssBuilderAPI.split('.'); let apiMajor = parseInt(version[0]); let apiMinor = parseInt(version[1]); version = builderAPI.split('.'); let builderMajor = parseInt(version[0]); let builderMinor = parseInt(version[1]); if (builderMajor !== apiMajor || builderMinor > apiMinor) { isCompatible = false; } } if (!isCompatible) { return reject(new Error('kss expected the builder to implement KssBuilderBase API version ' + kssBuilderAPI + '; version "' + builderAPI + '" is being used instead.')); } return resolve(newBuilder); }); } /** * Stores the given options. * * @param {Object} options An object of options to store. * @returns {KssBuilderBase} The `KssBuilderBase` object is returned to allow * chaining of methods. */ addOptions(options) { for (let key in options) { if (options.hasOwnProperty(key) && ['logFunction', 'logErrorFunction'].indexOf(key) === -1) { this.options[key] = options[key]; } } // Set the logging functions of the builder. if (typeof options.logFunction === 'function') { this.setLogFunction(options.logFunction); } if (typeof options.logErrorFunction === 'function') { this.setLogErrorFunction(options.logErrorFunction); } // Allow clone to be used without a path. We can't specify this default path // in the option definition or the clone flag would always be "on". if (options.clone === '' || options.clone === true) { this.options.clone = 'custom-builder'; } // Allow chaining. return this.normalizeOptions(Object.keys(options)); } /** * Returns the requested option or, if no key is specified, an object * containing all options. * * @param {string} [key] Optional name of the option to return. * @returns {*} The specified option or an object of all options. */ getOptions(key) { return key ? this.options[key] : this.options; } /** * Adds option definitions to the builder. * * Since kss-node is extensible, builders can define their own options that * users can configure. * * Each option definition object is key-compatble with * [yargs](https://www.npmjs.com/package/yargs), the command-line utility * used by kss-node's command line tool. * * If an option definition object has a: * - `multiple` property: if set to `false`, the corresponding option will be * normalized to a single value. Otherwise, it will be normalized to an * array of values. * - `path` property: if set to `true`, the corresponding option will be * normalized to a path, relative to the current working directory. * - `default` property: the corresponding option will default to this value. * * @param {object} optionDefinitions An object of option definitions. * @returns {KssBuilderBase} The `KssBuilderBase` object is returned to allow * chaining of methods. */ addOptionDefinitions(optionDefinitions) { for (let key in optionDefinitions) { // istanbul ignore else if (optionDefinitions.hasOwnProperty(key)) { // The "multiple" property defaults to true. if (typeof optionDefinitions[key].multiple === 'undefined') { optionDefinitions[key].multiple = true; } // The "path" property defaults to false. if (typeof optionDefinitions[key].path === 'undefined') { optionDefinitions[key].path = false; } this.optionDefinitions[key] = optionDefinitions[key]; } } // Allow chaining. return this.normalizeOptions(Object.keys(optionDefinitions)); } /** * Returns the requested option definition or, if no key is specified, an * object containing all option definitions. * * @param {string} [key] Optional name of option to return. * @returns {*} The specified option definition or an object of all option * definitions. */ getOptionDefinitions(key) { return key ? this.optionDefinitions[key] : this.optionDefinitions; } /** * Normalizes the options so that they are easy to use inside KSS. * * The option definitions specified with `addOptionDefinitions()` determine * how the options will be normalized. * * @private * @param {string[]} keys The keys to normalize. * @returns {KssBuilderBase} The `KssBuilderBase` object is returned to allow * chaining of methods. */ normalizeOptions(keys) { for (let key of keys) { if (typeof this.optionDefinitions[key] !== 'undefined') { if (typeof this.options[key] === 'undefined') { // Set the default setting. if (typeof this.optionDefinitions[key].default !== 'undefined') { this.options[key] = this.optionDefinitions[key].default; } } // If an option is specified multiple times, yargs will convert it into // an array, but leave it as a string otherwise. This makes accessing // the options inconsistent, so we make these options an array. if (this.optionDefinitions[key].multiple) { if (!(this.options[key] instanceof Array)) { if (typeof this.options[key] === 'undefined') { this.options[key] = []; } else { this.options[key] = [this.options[key]]; } } } else { // For options marked as "multiple: false", use the last value // specified, ignoring the others. if (this.options[key] instanceof Array) { this.options[key] = this.options[key].pop(); } } // Resolve any paths relative to the working directory. if (this.optionDefinitions[key].path) { if (key === 'builder') { this.options[key] = KssBuilderBase.builderResolve(this.options[key]); } else { if (this.options[key] instanceof Array) { /* eslint-disable no-loop-func */ this.options[key] = this.options[key].map(value => { return path.resolve(value); }); /* eslint-enable no-loop-func */ } else if (typeof this.options[key] === 'string') { this.options[key] = path.resolve(this.options[key]); } } } } } // Allow chaining. return this; } /* eslint-disable no-unused-vars */ /** * Logs a message to be reported to the user. * * Since a builder can be used in places other than the console, using * console.log() is inappropriate. The log() method should be used to pass * messages to the KSS system so it can report them to the user. * * @param {...string} message The message to log. * @returns {KssBuilderBase} The `KssBuilderBase` object is returned to allow * chaining of methods. */ log(message) { /* eslint-enable no-unused-vars */ this.logFunction.apply(null, arguments); // Allow chaining. return this; } /** * The `log()` method logs a message for the user. This method allows the * system to define the underlying function used by the log method to report * the message to the user. The default log function is a wrapper around * `console.log()`. * * @param {Function} logFunction Function to log a message to the user. * @returns {KssBuilderBase} The `KssBuilderBase` object is returned to allow * chaining of methods. */ setLogFunction(logFunction) { this.logFunction = logFunction; // Allow chaining. return this; } /* eslint-disable no-unused-vars */ /** * Logs an error to be reported to the user. * * Since a builder can be used in places other than the console, using * console.error() is inappropriate. The logError() method should be used to * pass error messages to the KSS system so it can report them to the user. * * @param {Error} error The error to log. * @returns {KssBuilderBase} The `KssBuilderBase` object is returned to allow * chaining of methods. */ logError(error) { /* eslint-enable no-unused-vars */ this.logErrorFunction.apply(null, arguments); // Allow chaining. return this; } /** * The `error()` method logs an error message for the user. This method allows * the system to define the underlying function used by the error method to * report the error message to the user. The default log error function is a * wrapper around `console.error()`. * * @param {Function} logErrorFunction Function to log a message to the user. * @returns {KssBuilderBase} The `KssBuilderBase` object is returned to allow * chaining of methods. */ setLogErrorFunction(logErrorFunction) { this.logErrorFunction = logErrorFunction; // Allow chaining. return this; } /** * Clone a builder's files. * * This method is fairly simple; it copies one directory to the specified * location. A sub-class of KssBuilderBase does not need to override this * method, but it can if it needs to do something more complicated. * * @param {string} builderPath Path to the builder to clone. * @param {string} destinationPath Path to the destination of the newly cloned * builder. * @returns {Promise.<null>} A `Promise` object resolving to `null`. */ clone(builderPath, destinationPath) { return fs.statAsync(destinationPath).catch(error => { // Pass the error on to the next .then(). return error; }).then(result => { // If we successfully get stats, the destination exists. if (!(result instanceof Error)) { return Promise.reject(new Error('This folder already exists: ' + destinationPath)); } // If the destination path does not exist, we copy the builder to it. // istanbul ignore else if (result.code === 'ENOENT') { return fs.copyAsync( builderPath, destinationPath, { clobber: true, filter: filePath => { // Only look at the part of the path inside the builder. let relativePath = path.sep + path.relative(builderPath, filePath); // Skip any files with a path matching: /node_modules or /. return (new RegExp('^(?!.*\\' + path.sep + '(node_modules$|\\.))')).test(relativePath); } } ); } else { // Otherwise, report the error. return Promise.reject(result); } }); } /** * Allow the builder to preform pre-build tasks or modify the KssStyleGuide * object. * * The method can be set by any KssBuilderBase sub-class to do any custom * tasks after the KssStyleGuide object is created and before the HTML style * guide is built. * * @param {KssStyleGuide} styleGuide The KSS style guide in object format. * @returns {Promise.<KssStyleGuide>} A `Promise` object resolving to a * `KssStyleGuide` object. */ prepare(styleGuide) { let sectionReferences, newSections = [], delim = styleGuide.referenceDelimiter(); // Create a list of references in the style guide. sectionReferences = styleGuide.sections().map(section => { return section.reference(); }); // Return an error if no KSS sections are found. if (sectionReferences.length === 0) { return Promise.reject(new Error('No KSS documentation discovered in source files.')); } sectionReferences.forEach(reference => { let refParts = reference.split(delim), checkReference = ''; // Split the reference into parts and ensure there are existing sections // for each level of the reference. e.g. For "a.b.c", check for existing // sections for "a" and "a.b". for (let i = 0; i < refParts.length - 1; i++) { checkReference += (checkReference ? delim : '') + refParts[i]; if (sectionReferences.indexOf(checkReference) === -1 && newSections.indexOf(checkReference) === -1) { newSections.push(checkReference); // Add the missing section to the style guide. styleGuide .autoInit(false) .sections({ header: checkReference, reference: checkReference }); } } }); // Re-init the style guide if we added new sections. if (newSections.length) { styleGuide.autoInit(true); } if (this.options.verbose) { this.log(''); this.log('Building your KSS style guide!'); this.log(''); this.log(' * KSS Source : ' + this.options.source.join(', ')); this.log(' * Destination : ' + this.options.destination); this.log(' * Builder : ' + this.options.builder); if (this.options.extend.length) { this.log(' * Extend : ' + this.options.extend.join(', ')); } } return Promise.resolve(styleGuide); } /** * A helper method that initializes the destination directory and optionally * copies the given asset directory from the builder. * * @param {string} assetDirectory The name of the asset directory to copy from * builder. * @returns {Promise} A promise to initialize the destination directory. */ prepareDestination(assetDirectory) { // Create a new destination directory. return fs.mkdirsAsync(this.options.destination).then(() => { if (assetDirectory) { // Optionally, copy the contents of the builder's asset directory. return fs.copyAsync( path.join(this.options.builder, assetDirectory), path.join(this.options.destination, assetDirectory), { clobber: true, filter: filePath => { // Only look at the part of the path inside the builder. let relativePath = path.sep + path.relative(this.options.builder, filePath); // Skip any files with a path matching: "/node_modules" or "/." return (new RegExp('^(?!.*\\' + path.sep + '(node_modules$|\\.))')).test(relativePath); } } ).catch(() => { // If the builder does not have a kss-assets folder, ignore the error. return Promise.resolve(); }); } else { return Promise.resolve(); } }); } /** * Helper method that loads modules to extend a templating system. * * The `--extend` option allows users to specify directories. This helper * method requires all .js files in the specified directories and calls the * default function exported with two parameters, the `templateEngine` object * and the options added to the builder. * * @param {object} templateEngine The templating system's main object; used by * the loaded module to extend the templating system. * @returns {Array.<Promise>} An array of `Promise` objects; one for each directory * given to the extend option. */ prepareExtend(templateEngine) { let promises = []; this.options.extend.forEach(directory => { promises.push( fs.readdirAsync(directory).then(files => { files.forEach(fileName => { if (path.extname(fileName) === '.js') { let extendFunction = require(path.join(directory, fileName)); if (typeof extendFunction === 'function') { extendFunction(templateEngine, this.options); } } }); }).catch((error) => { // Log the error, but allow operation to continue. if (this.options.verbose) { this.logError(new Error('An error occurred when attempting to use the "extend" directory, ' + directory + ': ' + error.message)); } return Promise.resolve(); }) ); }); return promises; } /** * Build the HTML files of the style guide given a KssStyleGuide object. * * @param {KssStyleGuide} styleGuide The KSS style guide in object format. * @returns {Promise.<KssStyleGuide>} A `Promise` object resolving to a * `KssStyleGuide` object. */ build(styleGuide) { return Promise.resolve(styleGuide); } /** * A helper method that can be used by sub-classes of KssBuilderBase when * implementing their build() method. * * The following options are required to use this helper method: * - readBuilderTemplate: A function that returns a promise to read/load a * template provided by the builder. * - readSectionTemplate: A function that returns a promise to read/load a * template specified by a section. * - loadInlineTemplate: A function that returns a promise to load an inline * template from markup. * - loadContext: A function that returns a promise to load the data context * given a template file path. * - getTemplate: A function that returns a promise to get a template by name. * - templateRender: A function that renders a template and returns the * markup. * - filenameToTemplateRef: A function that converts a filename into a unique * name used by the templating system. * - templateExtension: A string containing the file extension used by the * templates. * - emptyTemplate: A string containing markup for an empty template. * * @param {KssStyleGuide} styleGuide The KSS style guide in object format. * @param {object} options The options necessary to use this helper method. * @returns {Promise.<KssStyleGuide>} A `Promise` object resolving to a * `KssStyleGuide` object. */ buildGuide(styleGuide, options) { let readBuilderTemplate = options.readBuilderTemplate, readSectionTemplate = options.readSectionTemplate, loadInlineTemplate = options.loadInlineTemplate, loadContext = options.loadContext, // getTemplate = options.getTemplate, // templateRender = options.templateRender, filenameToTemplateRef = options.filenameToTemplateRef, templateExtension = options.templateExtension, emptyTemplate = options.emptyTemplate; this.styleGuide = styleGuide; this.sectionTemplates = {}; if (typeof this.templates === 'undefined') { this.templates = {}; } let buildTasks = [], readBuilderTask; // Optionally load/compile the index template. if (typeof this.templates.index === 'undefined') { readBuilderTask = readBuilderTemplate('index').then(template => { this.templates.index = template; return Promise.resolve(); }); } else { readBuilderTask = Promise.resolve(); } // Optionally load/compile the section template. if (typeof this.templates.section === 'undefined') { readBuilderTask = readBuilderTask.then(() => { return readBuilderTemplate('section').then(template => { this.templates.section = template; return Promise.resolve(); }).catch(() => { // If the section template cannot be read, use the index template. this.templates.section = this.templates.index; return Promise.resolve(); }); }); } // Optionally load/compile the item template. if (typeof this.templates.item === 'undefined') { readBuilderTask = readBuilderTask.then(() => { return readBuilderTemplate('item').then(template => { this.templates.item = template; return Promise.resolve(); }).catch(() => { // If the item template cannot be read, use the section template. this.templates.item = this.templates.section; return Promise.resolve(); }); }); } buildTasks.push(readBuilderTask); let sections = this.styleGuide.sections(); if (this.options.verbose && this.styleGuide.meta.files) { this.log(this.styleGuide.meta.files.map(file => { return ' - ' + file; }).join('\n')); } if (this.options.verbose) { this.log('...Determining section markup:'); } let sectionRoots = []; // Save the name of the template and its context for retrieval in // buildPage(), where we only know the reference. let saveTemplate = template => { this.sectionTemplates[template.reference] = { name: template.name, context: template.context, filename: template.file, exampleName: template.exampleName, exampleContext: template.exampleContext }; return Promise.resolve(); }; sections.forEach(section => { // Accumulate an array of section references for all sections at the root // of the style guide. let currentRoot = section.reference().split(/(?:\.|\ \-\ )/)[0]; if (sectionRoots.indexOf(currentRoot) === -1) { sectionRoots.push(currentRoot); } if (!section.markup()) { return; } // Register all the markup blocks as templates. let template = { name: section.reference(), reference: section.reference(), file: '', markup: section.markup(), context: {}, exampleName: false, exampleContext: {} }; // Check if the markup is a file path. if (template.markup.search('^[^\n]+\.(html|' + templateExtension + ')$') === -1) { if (this.options.verbose) { this.log(' - ' + template.reference + ': inline markup'); } buildTasks.push( loadInlineTemplate(template.name, template.markup).then(() => { return saveTemplate(template); }) ); } else { // Attempt to load the file path. section.custom('markupFile', template.markup); template.file = template.markup; template.name = filenameToTemplateRef(template.file); let findTemplates = [], matchFilename = path.basename(template.file), matchExampleFilename = 'kss-example-' + matchFilename; this.options.source.forEach(source => { let returnFilesAndSource = function(files) { return { source: source, files: files }; }; findTemplates.push(glob(source + '/**/' + template.file).then(returnFilesAndSource)); findTemplates.push(glob(source + '/**/' + path.join(path.dirname(template.file), matchExampleFilename)).then(returnFilesAndSource)); }); buildTasks.push( Promise.all(findTemplates).then(globMatches => { let foundTemplate = false, foundExample = false, loadTemplates = []; for (let globMatch of globMatches) { let files = globMatch.files, source = globMatch.source; if (!foundTemplate || !foundExample) { for (let file of files) { // Read the template from the first matched path. let filename = path.basename(file); if (!foundTemplate && filename === matchFilename) { foundTemplate = true; section.custom('markupFile', path.relative(source, file)); template.file = file; loadTemplates.push( readSectionTemplate(template.name, file).then(() => { /* eslint-disable max-nested-callbacks */ return loadContext(file).then(context => { template.context = context; return Promise.resolve(); }); /* eslint-enable max-nested-callbacks */ }) ); } else if (!foundExample && filename === matchExampleFilename) { foundExample = true; template.exampleName = 'kss-example-' + template.name; loadTemplates.push( readSectionTemplate(template.exampleName, file).then(() => { /* eslint-disable max-nested-callbacks */ return loadContext(file).then(context => { template.exampleContext = context; return Promise.resolve(); }); /* eslint-enable max-nested-callbacks */ }) ); } } } } // If the markup file is not found, note that in the style guide. if (!foundTemplate && !foundExample) { template.markup += ' NOT FOUND!'; if (!this.options.verbose) { this.log('WARNING: In section ' + template.reference + ', ' + template.markup); } loadTemplates.push( loadInlineTemplate(template.name, template.markup) ); } else if (!foundTemplate) { // If we found an example, but no template, load an empty // template. loadTemplates.push( loadInlineTemplate(template.name, emptyTemplate) ); } if (this.options.verbose) { this.log(' - ' + template.reference + ': ' + template.markup); } return Promise.all(loadTemplates).then(() => { return template; }); }).then(saveTemplate) ); } }); return Promise.all(buildTasks).then(() => { if (this.options.verbose) { this.log('...Building style guide pages:'); } let buildPageTasks = []; // Build the homepage. buildPageTasks.push(this.buildPage('index', options, null, [])); // Group all of the sections by their root reference, and make a page for // each. sectionRoots.forEach(rootReference => { buildPageTasks.push(this.buildPage('section', options, rootReference, this.styleGuide.sections(rootReference + '.*'))); }); // For each section, build a page which only has a single section on it. // istanbul ignore else if (this.templates.item) { sections.forEach(section => { buildPageTasks.push(this.buildPage('item', options, section.reference(), [section])); }); } return Promise.all(buildPageTasks); }).then(() => { // We return the KssStyleGuide, just like KssBuilderBase.build() does. return Promise.resolve(styleGuide); }); } /** * Add html tag generated by KSS to context * * @param {Object} context Additional context to give to the template when * it is rendered. */ prepareContext(context) { // Create the HTML to load the optional CSS and JS (if a sub-class hasn't already built it.) // istanbul ignore else if (typeof context.styles === 'undefined') { context.styles = ''; for (let key in this.options.css) { // istanbul ignore else if (this.options.css.hasOwnProperty(key)) { context.styles = context.styles + '<link rel="stylesheet" href="' + this.options.css[key] + '"/>\n'; } } } // istanbul ignore else if (typeof context.scripts === 'undefined') { context.scripts = ''; for (let key in this.options.js) { // istanbul ignore else if (this.options.js.hasOwnProperty(key)) { context.scripts = context.scripts + '<script src="' + this.options.js[key] + '"></script>\n'; } } } } /** * Renders the template for a section and saves it to a file. * * @param {string} templateName The name of the template to use. * @param {object} options The `getTemplate` and `templateRender` options * necessary to use this helper method; should be the same as the options * passed to BuildGuide(). * @param {string|null} pageReference The reference of the current page's root * section, or null if the current page is the homepage. * @param {Array} sections An array of KssSection objects. * @param {Object} [context] Additional context to give to the template when * it is rendered. * @returns {Promise} A `Promise` object. */ buildPage(templateName, options, pageReference, sections, context) { let getTemplate = options.getTemplate, getTemplateMarkup = options.getTemplateMarkup, templateRender = options.templateRender; context = context || {}; context.template = { isHomepage: templateName === 'index', isSection: templateName === 'section', isItem: templateName === 'item' }; context.styleGuide = this.styleGuide; context.sections = sections.map(section => { return section.toJSON(); }); context.hasNumericReferences = this.styleGuide.hasNumericReferences(); context.sectionTemplates = this.sectionTemplates; context.options = this.options; // Performs a shallow clone of the context clone so that the modifier_class // property can be modified without affecting the original value. let contextClone = data => { let clone = {}; for (var prop in data) { // istanbul ignore else if (data.hasOwnProperty(prop)) { clone[prop] = data[prop]; } } return clone; }; // Render the template for each section markup and modifier. return Promise.all( context.sections.map(section => { // If the section does not have any markup, render an empty string. if (!section.markup) { return Promise.resolve(); } else { // Load the information about this section's markup template. let templateInfo = this.sectionTemplates[section.reference]; let markupTask, exampleTask = false, exampleContext, modifierRender = (template, data, modifierClass) => { data = contextClone(data); /* eslint-disable camelcase */ data.modifier_class = (data.modifier_class ? data.modifier_class + ' ' : '') + modifierClass; /* eslint-enable camelcase */ return templateRender(template, data); }; // Set the section's markup variable. It's either the template's raw // markup or the rendered template. if (!this.options.markup && path.extname(templateInfo.filename) === '.' + options.templateExtension) { markupTask = getTemplateMarkup(templateInfo.name).then(markup => { // Copy the template's raw (unrendered) markup. section.markup = markup; }); } else { // Temporarily set it to "true" until we create a proper Promise. exampleTask = !(templateInfo.exampleName); markupTask = getTemplate(templateInfo.name).then(template => { section.markup = modifierRender( template, templateInfo.context, // Display the placeholder if the section has modifiers. (section.modifiers.length !== 0 ? this.options.placeholder : '') ); // If this section doesn't have a "kss-example" template, we will // be re-using this template for the rendered examples. if (!templateInfo.exampleName) { exampleTask = Promise.resolve(template); } return null; }); } // Pick a template to use for the rendered example variable. if (templateInfo.exampleName) { exampleTask = getTemplate(templateInfo.exampleName); exampleContext = templateInfo.exampleContext; } else { if (!exampleTask) { exampleTask = getTemplate(templateInfo.name); } exampleContext = templateInfo.context; } // Render the example variable and each modifier's markup. return markupTask.then(() => { return exampleTask; }).then(template => { section.example = templateRender(template, contextClone(exampleContext)); section.modifiers.forEach(modifier => { modifier.markup = modifierRender( template, exampleContext, modifier.className ); }); return Promise.resolve(); }); } }) ).then(() => { this.prepareContext(context); // Create a menu for the page (if a sub-class hasn't already built one.) // istanbul ignore else if (typeof context.menu === 'undefined') { context.menu = this.createMenu(pageReference); } // Determine the file name to use for this page. if (pageReference) { let rootSection = this.styleGuide.sections(pageReference); if (this.options.verbose) { this.log( ' - ' + templateName + ' ' + pageReference + ' [' + (rootSection.header() ? rootSection.header() : /* istanbul ignore next */ 'Unnamed') + ']' ); } // Convert the pageReference to be URI-friendly. pageReference = rootSection.referenceURI(); } else if (this.options.verbose) { this.log(' - homepage'); } let fileName = templateName + (pageReference ? '-' + pageReference : '') + '.html'; let getHomepageText; if (templateName !== 'index') { getHomepageText = Promise.resolve(); context.homepage = false; } else if (typeof context.homepage !== 'undefined') { getHomepageText = Promise.resolve(); } else { // Grab the homepage text if it hasn’t already been provided. getHomepageText = fs.readFile(this.options.homepage, 'utf8') .then(homePageText => { // Ensure homePageText is a non-false value. And run any results through // Markdown. context.homepage = homePageText ? md.render(homePageText) : ''; return Promise.resolve(); }) .catch(() => { if (this.options.verbose) { this.log(' ...no homepage content found in ' + path.relative(process.cwd(), this.options.homepage) + '.'); } else { this.log('WARNING: no homepage content found in ' + path.relative(process.cwd(), this.options.homepage) + '.'); } return ''; }); } return getHomepageText.then(() => { // Render the template and save it to the destination. return fs.writeFileAsync( path.join(this.options.destination, fileName), templateRender(this.templates[templateName], context) ); }); }); } /** * Creates a 2-level hierarchical menu from the style guide. * * @param {string} pageReference The reference of the root section of the page * being built. * @returns {Array} An array of menu items that can be used as a template * variable. */ createMenu(pageReference) { // Helper function that converts a section to a menu item. const toMenuItem = function(section) { // @TODO: Add an option to "include" the specific properties returned. let menuItem = section.toJSON(); // Remove data we definitely won't need for the menu. delete menuItem.markup; delete menuItem.modifiers; delete menuItem.parameters; delete menuItem.source; // Mark the current page in the menu. menuItem.isActive = (menuItem.reference === pageReference); // Mark any "deep" menu items. menuItem.isGrandChild = (menuItem.depth > 2); return menuItem; }; // Retrieve all the root sections of the style guide. return this.styleGuide.sections('x').map(rootSection => { let menuItem = toMenuItem(rootSection); // Retrieve the child sections for each of the root sections. menuItem.children = this.styleGuide.sections(rootSection.reference() + '.*').slice(1).map(toMenuItem); // Remove menu items that are deeper than the nav-depth option. menuItem.children = menuItem.children.filter(item => { return item.depth <= this.options['nav-depth']; }, this); return menuItem; }); } } module.exports = KssBuilderBase;