'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 to recursively parse for KSS comments, homepage, and markup'
},
'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',
default: '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);
});
}
/**
* 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(() => {
// 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';
}
}
}
// 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 {
// Grab the homepage text if it hasn't already been provided.
getHomepageText = (typeof context.homepage !== 'undefined') ? /* istanbul ignore next */ Promise.resolve() : Promise.all(
this.options.source.map(source => {
return glob(source + '/**/' + this.options.homepage);
})
).then(globMatches => {
for (let files of globMatches) {
if (files.length) {
// Read the file contents from the first matched path.
return fs.readFileAsync(files[0], 'utf8');
}
}
if (this.options.verbose) {
this.log(' ...no homepage content found in ' + this.options.homepage + '.');
} else {
this.log('WARNING: no homepage content found in ' + this.options.homepage + '.');
}
return '';
}).then(homePageText => {
// Ensure homePageText is a non-false value. And run any results through
// Markdown.
context.homepage = homePageText ? md.render(homePageText) : '';
return Promise.resolve();
});
}
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;