Source: builder/base/kss_builder_base.js

  1. 'use strict';
  2. /**
  3. * The `kss/builder/base` module loads the {@link KssBuilderBase} class.
  4. * ```
  5. * const KssBuilderBase = require('kss/builder/base');
  6. * ```
  7. * @module kss/builder/base
  8. */
  9. /* ***************************************************************
  10. See kss_builder_base_example.js for how to implement a builder.
  11. *************************************************************** */
  12. const md = require('../../lib/md.js');
  13. const path = require('path');
  14. const Promise = require('bluebird');
  15. const resolve = require('resolve'); // replace by require.resolve for node >= 8.9
  16. const fs = Promise.promisifyAll(require('fs-extra')),
  17. glob = Promise.promisify(require('glob')),
  18. kssBuilderAPI = '3.0';
  19. /**
  20. * A kss-node builder takes input files and builds a style guide.
  21. */
  22. class KssBuilderBase {
  23. /**
  24. * Create a KssBuilderBase object.
  25. *
  26. * This is the base object used by all kss-node builders.
  27. *
  28. * ```
  29. * const KssBuilderBase = require('kss/builder/base');
  30. * class KssBuilderCustom extends KssBuilderBase {
  31. * // Override methods of KssBuilderBase.
  32. * }
  33. * ```
  34. */
  35. constructor() {
  36. this.optionDefinitions = {};
  37. this.options = {};
  38. // Store the version of the builder API that the builder instance is
  39. // expecting; we will verify this in loadBuilder().
  40. this.API = 'undefined';
  41. // The log function defaults to console.log.
  42. this.setLogFunction(console.log);
  43. // The error logging function defaults to console.error.
  44. this.setLogErrorFunction(console.error);
  45. // Tell kss-node which Yargs-like options this builder has.
  46. this.addOptionDefinitions({
  47. 'source': {
  48. group: 'File locations:',
  49. string: true,
  50. path: true,
  51. describe: 'Source directory to recursively parse for KSS comments, homepage, and markup'
  52. },
  53. 'destination': {
  54. group: 'File locations:',
  55. string: true,
  56. path: true,
  57. multiple: false,
  58. describe: 'Destination directory of style guide',
  59. default: 'styleguide'
  60. },
  61. 'json': {
  62. group: 'File locations:',
  63. boolean: true,
  64. multiple: false,
  65. describe: 'Output a JSON object instead of building a style guide'
  66. },
  67. 'mask': {
  68. group: 'File locations:',
  69. alias: 'm',
  70. string: true,
  71. multiple: false,
  72. describe: 'Use a mask for detecting files containing KSS comments',
  73. default: '*.css|*.less|*.sass|*.scss|*.styl|*.stylus'
  74. },
  75. 'clone': {
  76. group: 'Builder:',
  77. string: true,
  78. path: true,
  79. multiple: false,
  80. describe: 'Clone a style guide builder to customize'
  81. },
  82. 'builder': {
  83. group: 'Builder:',
  84. alias: 'b',
  85. string: true,
  86. path: true,
  87. multiple: false,
  88. describe: 'Use the specified builder when building your style guide',
  89. default: path.join('builder', 'handlebars')
  90. },
  91. 'css': {
  92. group: 'Style guide:',
  93. string: true,
  94. describe: 'URL of a CSS file to include in the style guide'
  95. },
  96. 'js': {
  97. group: 'Style guide:',
  98. string: true,
  99. describe: 'URL of a JavaScript file to include in the style guide'
  100. },
  101. 'custom': {
  102. group: 'Style guide:',
  103. string: true,
  104. describe: 'Process a custom property name when parsing KSS comments'
  105. },
  106. 'extend': {
  107. group: 'Style guide:',
  108. string: true,
  109. path: true,
  110. describe: 'Location of modules to extend the templating system; see http://bit.ly/kss-wiki'
  111. },
  112. 'homepage': {
  113. group: 'Style guide:',
  114. string: true,
  115. multiple: false,
  116. describe: 'File name of the homepage\'s Markdown file',
  117. default: 'homepage.md'
  118. },
  119. 'markup': {
  120. group: 'Style guide:',
  121. boolean: true,
  122. multiple: false,
  123. describe: 'Render "markup" templates to HTML with the placeholder text',
  124. default: false
  125. },
  126. 'placeholder': {
  127. group: 'Style guide:',
  128. string: true,
  129. multiple: false,
  130. describe: 'Placeholder text to use for modifier classes',
  131. default: '[modifier class]'
  132. },
  133. 'nav-depth': {
  134. group: 'Style guide:',
  135. multiple: false,
  136. describe: 'Limit the navigation to the depth specified',
  137. default: 3
  138. },
  139. 'verbose': {
  140. count: true,
  141. multiple: false,
  142. describe: 'Display verbose details while building'
  143. }
  144. });
  145. }
  146. /**
  147. * Resolve the builder path from the given file path.
  148. *
  149. * Call this static method to resolve the builder path.
  150. *
  151. * @param {string} builder The path to a builder or a builder
  152. * to load.
  153. * @returns {string} resolved path
  154. */
  155. static builderResolve(builder) {
  156. const cmdDir = process.cwd();
  157. const kssDir = path.resolve(__dirname, '../..');
  158. const pathsToResolve = [
  159. cmdDir, // looking from commande path
  160. path.resolve(cmdDir, 'node_modules'), // looking for external module
  161. kssDir, // kss native builder
  162. path.resolve(kssDir, 'node_modules') // old npm version
  163. ];
  164. let resolvedPath = builder;
  165. try {
  166. resolvedPath = path.dirname(resolve.sync(builder, {paths: pathsToResolve}));
  167. } catch (e) {
  168. // console.log(`Your builder path "${builder}" is maybe wrong.`);
  169. }
  170. return resolvedPath;
  171. }
  172. /**
  173. * Loads the builder from the given file path or class.
  174. *
  175. * Call this static method to load the builder and verify the builder
  176. * implements the correct builder API version.
  177. *
  178. * @param {string|function} builderClass The path to a builder or a builder
  179. * class to load.
  180. * @returns {Promise.<KssBuilderBase>} A `Promise` object resolving to a
  181. * `KssBuilderBase` object, or one of its sub-classes.
  182. */
  183. static loadBuilder(builderClass) {
  184. return new Promise((resolve, reject) => {
  185. let newBuilder = {},
  186. SomeBuilder,
  187. isCompatible = true,
  188. builderAPI = 'undefined';
  189. try {
  190. // The parameter can be a class or constructor function.
  191. if (typeof builderClass === 'function') {
  192. SomeBuilder = builderClass;
  193. // If the parameter is a path, try to load the module.
  194. } else if (typeof builderClass === 'string') {
  195. SomeBuilder = require(builderClass);
  196. // Unexpected parameter.
  197. } else {
  198. return reject(new Error('Unexpected value for "builder"; should be a path to a module or a JavaScript Class.'));
  199. }
  200. // Check for a kss-node 2.0 template and KssGenerator. Template's were
  201. // objects that provided the builder (generator) as a property.
  202. if (typeof SomeBuilder === 'object'
  203. && SomeBuilder.hasOwnProperty('generator')
  204. && SomeBuilder.generator.hasOwnProperty('implementsAPI')) {
  205. isCompatible = false;
  206. builderAPI = SomeBuilder.generator.implementsAPI;
  207. // Try to create a new builder.
  208. } else {
  209. newBuilder = new SomeBuilder();
  210. }
  211. } catch (e) {
  212. // Builders don’t have to export their own builder class. If the builder
  213. // fails to export a builder class, we assume it wanted the default
  214. // builder. If the loader fails when given a string, we check if the
  215. // caller (either cli.js or kss.js) wanted the Twig builder and let the
  216. // caller recover from the thrown error.
  217. const supportedBuilders = [
  218. 'builder/twig',
  219. 'builder/nunjucks'
  220. ];
  221. // istanbul ignore if
  222. if (supportedBuilders.indexOf(builderClass) > -1) {
  223. return reject(new Error(`The specified builder, "${builderClass}", is not relative to the current working directory.`));
  224. } else {
  225. let KssBuilderHandlebars = require('../handlebars');
  226. newBuilder = new KssBuilderHandlebars();
  227. }
  228. }
  229. // Grab the builder API version.
  230. if (newBuilder.hasOwnProperty('API')) {
  231. builderAPI = newBuilder.API;
  232. }
  233. // Ensure KssBuilderBase is the base class.
  234. if (!(newBuilder instanceof KssBuilderBase)) {
  235. isCompatible = false;
  236. } else if (builderAPI.indexOf('.') === -1) {
  237. isCompatible = false;
  238. } else {
  239. let version = kssBuilderAPI.split('.');
  240. let apiMajor = parseInt(version[0]);
  241. let apiMinor = parseInt(version[1]);
  242. version = builderAPI.split('.');
  243. let builderMajor = parseInt(version[0]);
  244. let builderMinor = parseInt(version[1]);
  245. if (builderMajor !== apiMajor || builderMinor > apiMinor) {
  246. isCompatible = false;
  247. }
  248. }
  249. if (!isCompatible) {
  250. return reject(new Error('kss expected the builder to implement KssBuilderBase API version ' + kssBuilderAPI + '; version "' + builderAPI + '" is being used instead.'));
  251. }
  252. return resolve(newBuilder);
  253. });
  254. }
  255. /**
  256. * Stores the given options.
  257. *
  258. * @param {Object} options An object of options to store.
  259. * @returns {KssBuilderBase} The `KssBuilderBase` object is returned to allow
  260. * chaining of methods.
  261. */
  262. addOptions(options) {
  263. for (let key in options) {
  264. if (options.hasOwnProperty(key) && ['logFunction', 'logErrorFunction'].indexOf(key) === -1) {
  265. this.options[key] = options[key];
  266. }
  267. }
  268. // Set the logging functions of the builder.
  269. if (typeof options.logFunction === 'function') {
  270. this.setLogFunction(options.logFunction);
  271. }
  272. if (typeof options.logErrorFunction === 'function') {
  273. this.setLogErrorFunction(options.logErrorFunction);
  274. }
  275. // Allow clone to be used without a path. We can't specify this default path
  276. // in the option definition or the clone flag would always be "on".
  277. if (options.clone === '' || options.clone === true) {
  278. this.options.clone = 'custom-builder';
  279. }
  280. // Allow chaining.
  281. return this.normalizeOptions(Object.keys(options));
  282. }
  283. /**
  284. * Returns the requested option or, if no key is specified, an object
  285. * containing all options.
  286. *
  287. * @param {string} [key] Optional name of the option to return.
  288. * @returns {*} The specified option or an object of all options.
  289. */
  290. getOptions(key) {
  291. return key ? this.options[key] : this.options;
  292. }
  293. /**
  294. * Adds option definitions to the builder.
  295. *
  296. * Since kss-node is extensible, builders can define their own options that
  297. * users can configure.
  298. *
  299. * Each option definition object is key-compatble with
  300. * [yargs](https://www.npmjs.com/package/yargs), the command-line utility
  301. * used by kss-node's command line tool.
  302. *
  303. * If an option definition object has a:
  304. * - `multiple` property: if set to `false`, the corresponding option will be
  305. * normalized to a single value. Otherwise, it will be normalized to an
  306. * array of values.
  307. * - `path` property: if set to `true`, the corresponding option will be
  308. * normalized to a path, relative to the current working directory.
  309. * - `default` property: the corresponding option will default to this value.
  310. *
  311. * @param {object} optionDefinitions An object of option definitions.
  312. * @returns {KssBuilderBase} The `KssBuilderBase` object is returned to allow
  313. * chaining of methods.
  314. */
  315. addOptionDefinitions(optionDefinitions) {
  316. for (let key in optionDefinitions) {
  317. // istanbul ignore else
  318. if (optionDefinitions.hasOwnProperty(key)) {
  319. // The "multiple" property defaults to true.
  320. if (typeof optionDefinitions[key].multiple === 'undefined') {
  321. optionDefinitions[key].multiple = true;
  322. }
  323. // The "path" property defaults to false.
  324. if (typeof optionDefinitions[key].path === 'undefined') {
  325. optionDefinitions[key].path = false;
  326. }
  327. this.optionDefinitions[key] = optionDefinitions[key];
  328. }
  329. }
  330. // Allow chaining.
  331. return this.normalizeOptions(Object.keys(optionDefinitions));
  332. }
  333. /**
  334. * Returns the requested option definition or, if no key is specified, an
  335. * object containing all option definitions.
  336. *
  337. * @param {string} [key] Optional name of option to return.
  338. * @returns {*} The specified option definition or an object of all option
  339. * definitions.
  340. */
  341. getOptionDefinitions(key) {
  342. return key ? this.optionDefinitions[key] : this.optionDefinitions;
  343. }
  344. /**
  345. * Normalizes the options so that they are easy to use inside KSS.
  346. *
  347. * The option definitions specified with `addOptionDefinitions()` determine
  348. * how the options will be normalized.
  349. *
  350. * @private
  351. * @param {string[]} keys The keys to normalize.
  352. * @returns {KssBuilderBase} The `KssBuilderBase` object is returned to allow
  353. * chaining of methods.
  354. */
  355. normalizeOptions(keys) {
  356. for (let key of keys) {
  357. if (typeof this.optionDefinitions[key] !== 'undefined') {
  358. if (typeof this.options[key] === 'undefined') {
  359. // Set the default setting.
  360. if (typeof this.optionDefinitions[key].default !== 'undefined') {
  361. this.options[key] = this.optionDefinitions[key].default;
  362. }
  363. }
  364. // If an option is specified multiple times, yargs will convert it into
  365. // an array, but leave it as a string otherwise. This makes accessing
  366. // the options inconsistent, so we make these options an array.
  367. if (this.optionDefinitions[key].multiple) {
  368. if (!(this.options[key] instanceof Array)) {
  369. if (typeof this.options[key] === 'undefined') {
  370. this.options[key] = [];
  371. } else {
  372. this.options[key] = [this.options[key]];
  373. }
  374. }
  375. } else {
  376. // For options marked as "multiple: false", use the last value
  377. // specified, ignoring the others.
  378. if (this.options[key] instanceof Array) {
  379. this.options[key] = this.options[key].pop();
  380. }
  381. }
  382. // Resolve any paths relative to the working directory.
  383. if (this.optionDefinitions[key].path) {
  384. if (key === 'builder') {
  385. this.options[key] = KssBuilderBase.builderResolve(this.options[key]);
  386. } else {
  387. if (this.options[key] instanceof Array) {
  388. /* eslint-disable no-loop-func */
  389. this.options[key] = this.options[key].map(value => {
  390. return path.resolve(value);
  391. });
  392. /* eslint-enable no-loop-func */
  393. } else if (typeof this.options[key] === 'string') {
  394. this.options[key] = path.resolve(this.options[key]);
  395. }
  396. }
  397. }
  398. }
  399. }
  400. // Allow chaining.
  401. return this;
  402. }
  403. /* eslint-disable no-unused-vars */
  404. /**
  405. * Logs a message to be reported to the user.
  406. *
  407. * Since a builder can be used in places other than the console, using
  408. * console.log() is inappropriate. The log() method should be used to pass
  409. * messages to the KSS system so it can report them to the user.
  410. *
  411. * @param {...string} message The message to log.
  412. * @returns {KssBuilderBase} The `KssBuilderBase` object is returned to allow
  413. * chaining of methods.
  414. */
  415. log(message) {
  416. /* eslint-enable no-unused-vars */
  417. this.logFunction.apply(null, arguments);
  418. // Allow chaining.
  419. return this;
  420. }
  421. /**
  422. * The `log()` method logs a message for the user. This method allows the
  423. * system to define the underlying function used by the log method to report
  424. * the message to the user. The default log function is a wrapper around
  425. * `console.log()`.
  426. *
  427. * @param {Function} logFunction Function to log a message to the user.
  428. * @returns {KssBuilderBase} The `KssBuilderBase` object is returned to allow
  429. * chaining of methods.
  430. */
  431. setLogFunction(logFunction) {
  432. this.logFunction = logFunction;
  433. // Allow chaining.
  434. return this;
  435. }
  436. /* eslint-disable no-unused-vars */
  437. /**
  438. * Logs an error to be reported to the user.
  439. *
  440. * Since a builder can be used in places other than the console, using
  441. * console.error() is inappropriate. The logError() method should be used to
  442. * pass error messages to the KSS system so it can report them to the user.
  443. *
  444. * @param {Error} error The error to log.
  445. * @returns {KssBuilderBase} The `KssBuilderBase` object is returned to allow
  446. * chaining of methods.
  447. */
  448. logError(error) {
  449. /* eslint-enable no-unused-vars */
  450. this.logErrorFunction.apply(null, arguments);
  451. // Allow chaining.
  452. return this;
  453. }
  454. /**
  455. * The `error()` method logs an error message for the user. This method allows
  456. * the system to define the underlying function used by the error method to
  457. * report the error message to the user. The default log error function is a
  458. * wrapper around `console.error()`.
  459. *
  460. * @param {Function} logErrorFunction Function to log a message to the user.
  461. * @returns {KssBuilderBase} The `KssBuilderBase` object is returned to allow
  462. * chaining of methods.
  463. */
  464. setLogErrorFunction(logErrorFunction) {
  465. this.logErrorFunction = logErrorFunction;
  466. // Allow chaining.
  467. return this;
  468. }
  469. /**
  470. * Clone a builder's files.
  471. *
  472. * This method is fairly simple; it copies one directory to the specified
  473. * location. A sub-class of KssBuilderBase does not need to override this
  474. * method, but it can if it needs to do something more complicated.
  475. *
  476. * @param {string} builderPath Path to the builder to clone.
  477. * @param {string} destinationPath Path to the destination of the newly cloned
  478. * builder.
  479. * @returns {Promise.<null>} A `Promise` object resolving to `null`.
  480. */
  481. clone(builderPath, destinationPath) {
  482. return fs.statAsync(destinationPath).catch(error => {
  483. // Pass the error on to the next .then().
  484. return error;
  485. }).then(result => {
  486. // If we successfully get stats, the destination exists.
  487. if (!(result instanceof Error)) {
  488. return Promise.reject(new Error('This folder already exists: ' + destinationPath));
  489. }
  490. // If the destination path does not exist, we copy the builder to it.
  491. // istanbul ignore else
  492. if (result.code === 'ENOENT') {
  493. return fs.copyAsync(
  494. builderPath,
  495. destinationPath,
  496. {
  497. clobber: true,
  498. filter: filePath => {
  499. // Only look at the part of the path inside the builder.
  500. let relativePath = path.sep + path.relative(builderPath, filePath);
  501. // Skip any files with a path matching: /node_modules or /.
  502. return (new RegExp('^(?!.*\\' + path.sep + '(node_modules$|\\.))')).test(relativePath);
  503. }
  504. }
  505. );
  506. } else {
  507. // Otherwise, report the error.
  508. return Promise.reject(result);
  509. }
  510. });
  511. }
  512. /**
  513. * Allow the builder to preform pre-build tasks or modify the KssStyleGuide
  514. * object.
  515. *
  516. * The method can be set by any KssBuilderBase sub-class to do any custom
  517. * tasks after the KssStyleGuide object is created and before the HTML style
  518. * guide is built.
  519. *
  520. * @param {KssStyleGuide} styleGuide The KSS style guide in object format.
  521. * @returns {Promise.<KssStyleGuide>} A `Promise` object resolving to a
  522. * `KssStyleGuide` object.
  523. */
  524. prepare(styleGuide) {
  525. let sectionReferences,
  526. newSections = [],
  527. delim = styleGuide.referenceDelimiter();
  528. // Create a list of references in the style guide.
  529. sectionReferences = styleGuide.sections().map(section => {
  530. return section.reference();
  531. });
  532. // Return an error if no KSS sections are found.
  533. if (sectionReferences.length === 0) {
  534. return Promise.reject(new Error('No KSS documentation discovered in source files.'));
  535. }
  536. sectionReferences.forEach(reference => {
  537. let refParts = reference.split(delim),
  538. checkReference = '';
  539. // Split the reference into parts and ensure there are existing sections
  540. // for each level of the reference. e.g. For "a.b.c", check for existing
  541. // sections for "a" and "a.b".
  542. for (let i = 0; i < refParts.length - 1; i++) {
  543. checkReference += (checkReference ? delim : '') + refParts[i];
  544. if (sectionReferences.indexOf(checkReference) === -1 && newSections.indexOf(checkReference) === -1) {
  545. newSections.push(checkReference);
  546. // Add the missing section to the style guide.
  547. styleGuide
  548. .autoInit(false)
  549. .sections({
  550. header: checkReference,
  551. reference: checkReference
  552. });
  553. }
  554. }
  555. });
  556. // Re-init the style guide if we added new sections.
  557. if (newSections.length) {
  558. styleGuide.autoInit(true);
  559. }
  560. if (this.options.verbose) {
  561. this.log('');
  562. this.log('Building your KSS style guide!');
  563. this.log('');
  564. this.log(' * KSS Source : ' + this.options.source.join(', '));
  565. this.log(' * Destination : ' + this.options.destination);
  566. this.log(' * Builder : ' + this.options.builder);
  567. if (this.options.extend.length) {
  568. this.log(' * Extend : ' + this.options.extend.join(', '));
  569. }
  570. }
  571. return Promise.resolve(styleGuide);
  572. }
  573. /**
  574. * A helper method that initializes the destination directory and optionally
  575. * copies the given asset directory from the builder.
  576. *
  577. * @param {string} assetDirectory The name of the asset directory to copy from
  578. * builder.
  579. * @returns {Promise} A promise to initialize the destination directory.
  580. */
  581. prepareDestination(assetDirectory) {
  582. // Create a new destination directory.
  583. return fs.mkdirsAsync(this.options.destination).then(() => {
  584. if (assetDirectory) {
  585. // Optionally, copy the contents of the builder's asset directory.
  586. return fs.copyAsync(
  587. path.join(this.options.builder, assetDirectory),
  588. path.join(this.options.destination, assetDirectory),
  589. {
  590. clobber: true,
  591. filter: filePath => {
  592. // Only look at the part of the path inside the builder.
  593. let relativePath = path.sep + path.relative(this.options.builder, filePath);
  594. // Skip any files with a path matching: "/node_modules" or "/."
  595. return (new RegExp('^(?!.*\\' + path.sep + '(node_modules$|\\.))')).test(relativePath);
  596. }
  597. }
  598. ).catch(() => {
  599. // If the builder does not have a kss-assets folder, ignore the error.
  600. return Promise.resolve();
  601. });
  602. } else {
  603. return Promise.resolve();
  604. }
  605. });
  606. }
  607. /**
  608. * Helper method that loads modules to extend a templating system.
  609. *
  610. * The `--extend` option allows users to specify directories. This helper
  611. * method requires all .js files in the specified directories and calls the
  612. * default function exported with two parameters, the `templateEngine` object
  613. * and the options added to the builder.
  614. *
  615. * @param {object} templateEngine The templating system's main object; used by
  616. * the loaded module to extend the templating system.
  617. * @returns {Array.<Promise>} An array of `Promise` objects; one for each directory
  618. * given to the extend option.
  619. */
  620. prepareExtend(templateEngine) {
  621. let promises = [];
  622. this.options.extend.forEach(directory => {
  623. promises.push(
  624. fs.readdirAsync(directory).then(files => {
  625. files.forEach(fileName => {
  626. if (path.extname(fileName) === '.js') {
  627. let extendFunction = require(path.join(directory, fileName));
  628. if (typeof extendFunction === 'function') {
  629. extendFunction(templateEngine, this.options);
  630. }
  631. }
  632. });
  633. }).catch((error) => {
  634. // Log the error, but allow operation to continue.
  635. if (this.options.verbose) {
  636. this.logError(new Error('An error occurred when attempting to use the "extend" directory, ' + directory + ': ' + error.message));
  637. }
  638. return Promise.resolve();
  639. })
  640. );
  641. });
  642. return promises;
  643. }
  644. /**
  645. * Build the HTML files of the style guide given a KssStyleGuide object.
  646. *
  647. * @param {KssStyleGuide} styleGuide The KSS style guide in object format.
  648. * @returns {Promise.<KssStyleGuide>} A `Promise` object resolving to a
  649. * `KssStyleGuide` object.
  650. */
  651. build(styleGuide) {
  652. return Promise.resolve(styleGuide);
  653. }
  654. /**
  655. * A helper method that can be used by sub-classes of KssBuilderBase when
  656. * implementing their build() method.
  657. *
  658. * The following options are required to use this helper method:
  659. * - readBuilderTemplate: A function that returns a promise to read/load a
  660. * template provided by the builder.
  661. * - readSectionTemplate: A function that returns a promise to read/load a
  662. * template specified by a section.
  663. * - loadInlineTemplate: A function that returns a promise to load an inline
  664. * template from markup.
  665. * - loadContext: A function that returns a promise to load the data context
  666. * given a template file path.
  667. * - getTemplate: A function that returns a promise to get a template by name.
  668. * - templateRender: A function that renders a template and returns the
  669. * markup.
  670. * - filenameToTemplateRef: A function that converts a filename into a unique
  671. * name used by the templating system.
  672. * - templateExtension: A string containing the file extension used by the
  673. * templates.
  674. * - emptyTemplate: A string containing markup for an empty template.
  675. *
  676. * @param {KssStyleGuide} styleGuide The KSS style guide in object format.
  677. * @param {object} options The options necessary to use this helper method.
  678. * @returns {Promise.<KssStyleGuide>} A `Promise` object resolving to a
  679. * `KssStyleGuide` object.
  680. */
  681. buildGuide(styleGuide, options) {
  682. let readBuilderTemplate = options.readBuilderTemplate,
  683. readSectionTemplate = options.readSectionTemplate,
  684. loadInlineTemplate = options.loadInlineTemplate,
  685. loadContext = options.loadContext,
  686. // getTemplate = options.getTemplate,
  687. // templateRender = options.templateRender,
  688. filenameToTemplateRef = options.filenameToTemplateRef,
  689. templateExtension = options.templateExtension,
  690. emptyTemplate = options.emptyTemplate;
  691. this.styleGuide = styleGuide;
  692. this.sectionTemplates = {};
  693. if (typeof this.templates === 'undefined') {
  694. this.templates = {};
  695. }
  696. let buildTasks = [],
  697. readBuilderTask;
  698. // Optionally load/compile the index template.
  699. if (typeof this.templates.index === 'undefined') {
  700. readBuilderTask = readBuilderTemplate('index').then(template => {
  701. this.templates.index = template;
  702. return Promise.resolve();
  703. });
  704. } else {
  705. readBuilderTask = Promise.resolve();
  706. }
  707. // Optionally load/compile the section template.
  708. if (typeof this.templates.section === 'undefined') {
  709. readBuilderTask = readBuilderTask.then(() => {
  710. return readBuilderTemplate('section').then(template => {
  711. this.templates.section = template;
  712. return Promise.resolve();
  713. }).catch(() => {
  714. // If the section template cannot be read, use the index template.
  715. this.templates.section = this.templates.index;
  716. return Promise.resolve();
  717. });
  718. });
  719. }
  720. // Optionally load/compile the item template.
  721. if (typeof this.templates.item === 'undefined') {
  722. readBuilderTask = readBuilderTask.then(() => {
  723. return readBuilderTemplate('item').then(template => {
  724. this.templates.item = template;
  725. return Promise.resolve();
  726. }).catch(() => {
  727. // If the item template cannot be read, use the section template.
  728. this.templates.item = this.templates.section;
  729. return Promise.resolve();
  730. });
  731. });
  732. }
  733. buildTasks.push(readBuilderTask);
  734. let sections = this.styleGuide.sections();
  735. if (this.options.verbose && this.styleGuide.meta.files) {
  736. this.log(this.styleGuide.meta.files.map(file => {
  737. return ' - ' + file;
  738. }).join('\n'));
  739. }
  740. if (this.options.verbose) {
  741. this.log('...Determining section markup:');
  742. }
  743. let sectionRoots = [];
  744. // Save the name of the template and its context for retrieval in
  745. // buildPage(), where we only know the reference.
  746. let saveTemplate = template => {
  747. this.sectionTemplates[template.reference] = {
  748. name: template.name,
  749. context: template.context,
  750. filename: template.file,
  751. exampleName: template.exampleName,
  752. exampleContext: template.exampleContext
  753. };
  754. return Promise.resolve();
  755. };
  756. sections.forEach(section => {
  757. // Accumulate an array of section references for all sections at the root
  758. // of the style guide.
  759. let currentRoot = section.reference().split(/(?:\.|\ \-\ )/)[0];
  760. if (sectionRoots.indexOf(currentRoot) === -1) {
  761. sectionRoots.push(currentRoot);
  762. }
  763. if (!section.markup()) {
  764. return;
  765. }
  766. // Register all the markup blocks as templates.
  767. let template = {
  768. name: section.reference(),
  769. reference: section.reference(),
  770. file: '',
  771. markup: section.markup(),
  772. context: {},
  773. exampleName: false,
  774. exampleContext: {}
  775. };
  776. // Check if the markup is a file path.
  777. if (template.markup.search('^[^\n]+\.(html|' + templateExtension + ')$') === -1) {
  778. if (this.options.verbose) {
  779. this.log(' - ' + template.reference + ': inline markup');
  780. }
  781. buildTasks.push(
  782. loadInlineTemplate(template.name, template.markup).then(() => {
  783. return saveTemplate(template);
  784. })
  785. );
  786. } else {
  787. // Attempt to load the file path.
  788. section.custom('markupFile', template.markup);
  789. template.file = template.markup;
  790. template.name = filenameToTemplateRef(template.file);
  791. let findTemplates = [],
  792. matchFilename = path.basename(template.file),
  793. matchExampleFilename = 'kss-example-' + matchFilename;
  794. this.options.source.forEach(source => {
  795. let returnFilesAndSource = function(files) {
  796. return {
  797. source: source,
  798. files: files
  799. };
  800. };
  801. findTemplates.push(glob(source + '/**/' + template.file).then(returnFilesAndSource));
  802. findTemplates.push(glob(source + '/**/' + path.join(path.dirname(template.file), matchExampleFilename)).then(returnFilesAndSource));
  803. });
  804. buildTasks.push(
  805. Promise.all(findTemplates).then(globMatches => {
  806. let foundTemplate = false,
  807. foundExample = false,
  808. loadTemplates = [];
  809. for (let globMatch of globMatches) {
  810. let files = globMatch.files,
  811. source = globMatch.source;
  812. if (!foundTemplate || !foundExample) {
  813. for (let file of files) {
  814. // Read the template from the first matched path.
  815. let filename = path.basename(file);
  816. if (!foundTemplate && filename === matchFilename) {
  817. foundTemplate = true;
  818. section.custom('markupFile', path.relative(source, file));
  819. template.file = file;
  820. loadTemplates.push(
  821. readSectionTemplate(template.name, file).then(() => {
  822. /* eslint-disable max-nested-callbacks */
  823. return loadContext(file).then(context => {
  824. template.context = context;
  825. return Promise.resolve();
  826. });
  827. /* eslint-enable max-nested-callbacks */
  828. })
  829. );
  830. } else if (!foundExample && filename === matchExampleFilename) {
  831. foundExample = true;
  832. template.exampleName = 'kss-example-' + template.name;
  833. loadTemplates.push(
  834. readSectionTemplate(template.exampleName, file).then(() => {
  835. /* eslint-disable max-nested-callbacks */
  836. return loadContext(file).then(context => {
  837. template.exampleContext = context;
  838. return Promise.resolve();
  839. });
  840. /* eslint-enable max-nested-callbacks */
  841. })
  842. );
  843. }
  844. }
  845. }
  846. }
  847. // If the markup file is not found, note that in the style guide.
  848. if (!foundTemplate && !foundExample) {
  849. template.markup += ' NOT FOUND!';
  850. if (!this.options.verbose) {
  851. this.log('WARNING: In section ' + template.reference + ', ' + template.markup);
  852. }
  853. loadTemplates.push(
  854. loadInlineTemplate(template.name, template.markup)
  855. );
  856. } else if (!foundTemplate) {
  857. // If we found an example, but no template, load an empty
  858. // template.
  859. loadTemplates.push(
  860. loadInlineTemplate(template.name, emptyTemplate)
  861. );
  862. }
  863. if (this.options.verbose) {
  864. this.log(' - ' + template.reference + ': ' + template.markup);
  865. }
  866. return Promise.all(loadTemplates).then(() => {
  867. return template;
  868. });
  869. }).then(saveTemplate)
  870. );
  871. }
  872. });
  873. return Promise.all(buildTasks).then(() => {
  874. if (this.options.verbose) {
  875. this.log('...Building style guide pages:');
  876. }
  877. let buildPageTasks = [];
  878. // Build the homepage.
  879. buildPageTasks.push(this.buildPage('index', options, null, []));
  880. // Group all of the sections by their root reference, and make a page for
  881. // each.
  882. sectionRoots.forEach(rootReference => {
  883. buildPageTasks.push(this.buildPage('section', options, rootReference, this.styleGuide.sections(rootReference + '.*')));
  884. });
  885. // For each section, build a page which only has a single section on it.
  886. // istanbul ignore else
  887. if (this.templates.item) {
  888. sections.forEach(section => {
  889. buildPageTasks.push(this.buildPage('item', options, section.reference(), [section]));
  890. });
  891. }
  892. return Promise.all(buildPageTasks);
  893. }).then(() => {
  894. // We return the KssStyleGuide, just like KssBuilderBase.build() does.
  895. return Promise.resolve(styleGuide);
  896. });
  897. }
  898. /**
  899. * Renders the template for a section and saves it to a file.
  900. *
  901. * @param {string} templateName The name of the template to use.
  902. * @param {object} options The `getTemplate` and `templateRender` options
  903. * necessary to use this helper method; should be the same as the options
  904. * passed to BuildGuide().
  905. * @param {string|null} pageReference The reference of the current page's root
  906. * section, or null if the current page is the homepage.
  907. * @param {Array} sections An array of KssSection objects.
  908. * @param {Object} [context] Additional context to give to the template when
  909. * it is rendered.
  910. * @returns {Promise} A `Promise` object.
  911. */
  912. buildPage(templateName, options, pageReference, sections, context) {
  913. let getTemplate = options.getTemplate,
  914. getTemplateMarkup = options.getTemplateMarkup,
  915. templateRender = options.templateRender;
  916. context = context || {};
  917. context.template = {
  918. isHomepage: templateName === 'index',
  919. isSection: templateName === 'section',
  920. isItem: templateName === 'item'
  921. };
  922. context.styleGuide = this.styleGuide;
  923. context.sections = sections.map(section => {
  924. return section.toJSON();
  925. });
  926. context.hasNumericReferences = this.styleGuide.hasNumericReferences();
  927. context.sectionTemplates = this.sectionTemplates;
  928. context.options = this.options;
  929. // Performs a shallow clone of the context clone so that the modifier_class
  930. // property can be modified without affecting the original value.
  931. let contextClone = data => {
  932. let clone = {};
  933. for (var prop in data) {
  934. // istanbul ignore else
  935. if (data.hasOwnProperty(prop)) {
  936. clone[prop] = data[prop];
  937. }
  938. }
  939. return clone;
  940. };
  941. // Render the template for each section markup and modifier.
  942. return Promise.all(
  943. context.sections.map(section => {
  944. // If the section does not have any markup, render an empty string.
  945. if (!section.markup) {
  946. return Promise.resolve();
  947. } else {
  948. // Load the information about this section's markup template.
  949. let templateInfo = this.sectionTemplates[section.reference];
  950. let markupTask,
  951. exampleTask = false,
  952. exampleContext,
  953. modifierRender = (template, data, modifierClass) => {
  954. data = contextClone(data);
  955. /* eslint-disable camelcase */
  956. data.modifier_class = (data.modifier_class ? data.modifier_class + ' ' : '') + modifierClass;
  957. /* eslint-enable camelcase */
  958. return templateRender(template, data);
  959. };
  960. // Set the section's markup variable. It's either the template's raw
  961. // markup or the rendered template.
  962. if (!this.options.markup && path.extname(templateInfo.filename) === '.' + options.templateExtension) {
  963. markupTask = getTemplateMarkup(templateInfo.name).then(markup => {
  964. // Copy the template's raw (unrendered) markup.
  965. section.markup = markup;
  966. });
  967. } else {
  968. // Temporarily set it to "true" until we create a proper Promise.
  969. exampleTask = !(templateInfo.exampleName);
  970. markupTask = getTemplate(templateInfo.name).then(template => {
  971. section.markup = modifierRender(
  972. template,
  973. templateInfo.context,
  974. // Display the placeholder if the section has modifiers.
  975. (section.modifiers.length !== 0 ? this.options.placeholder : '')
  976. );
  977. // If this section doesn't have a "kss-example" template, we will
  978. // be re-using this template for the rendered examples.
  979. if (!templateInfo.exampleName) {
  980. exampleTask = Promise.resolve(template);
  981. }
  982. return null;
  983. });
  984. }
  985. // Pick a template to use for the rendered example variable.
  986. if (templateInfo.exampleName) {
  987. exampleTask = getTemplate(templateInfo.exampleName);
  988. exampleContext = templateInfo.exampleContext;
  989. } else {
  990. if (!exampleTask) {
  991. exampleTask = getTemplate(templateInfo.name);
  992. }
  993. exampleContext = templateInfo.context;
  994. }
  995. // Render the example variable and each modifier's markup.
  996. return markupTask.then(() => {
  997. return exampleTask;
  998. }).then(template => {
  999. section.example = templateRender(template, contextClone(exampleContext));
  1000. section.modifiers.forEach(modifier => {
  1001. modifier.markup = modifierRender(
  1002. template,
  1003. exampleContext,
  1004. modifier.className
  1005. );
  1006. });
  1007. return Promise.resolve();
  1008. });
  1009. }
  1010. })
  1011. ).then(() => {
  1012. // Create the HTML to load the optional CSS and JS (if a sub-class hasn't already built it.)
  1013. // istanbul ignore else
  1014. if (typeof context.styles === 'undefined') {
  1015. context.styles = '';
  1016. for (let key in this.options.css) {
  1017. // istanbul ignore else
  1018. if (this.options.css.hasOwnProperty(key)) {
  1019. context.styles = context.styles + '<link rel="stylesheet" href="' + this.options.css[key] + '">\n';
  1020. }
  1021. }
  1022. }
  1023. // istanbul ignore else
  1024. if (typeof context.scripts === 'undefined') {
  1025. context.scripts = '';
  1026. for (let key in this.options.js) {
  1027. // istanbul ignore else
  1028. if (this.options.js.hasOwnProperty(key)) {
  1029. context.scripts = context.scripts + '<script src="' + this.options.js[key] + '"></script>\n';
  1030. }
  1031. }
  1032. }
  1033. // Create a menu for the page (if a sub-class hasn't already built one.)
  1034. // istanbul ignore else
  1035. if (typeof context.menu === 'undefined') {
  1036. context.menu = this.createMenu(pageReference);
  1037. }
  1038. // Determine the file name to use for this page.
  1039. if (pageReference) {
  1040. let rootSection = this.styleGuide.sections(pageReference);
  1041. if (this.options.verbose) {
  1042. this.log(
  1043. ' - ' + templateName + ' ' + pageReference
  1044. + ' ['
  1045. + (rootSection.header() ? rootSection.header() : /* istanbul ignore next */ 'Unnamed')
  1046. + ']'
  1047. );
  1048. }
  1049. // Convert the pageReference to be URI-friendly.
  1050. pageReference = rootSection.referenceURI();
  1051. } else if (this.options.verbose) {
  1052. this.log(' - homepage');
  1053. }
  1054. let fileName = templateName + (pageReference ? '-' + pageReference : '') + '.html';
  1055. let getHomepageText;
  1056. if (templateName !== 'index') {
  1057. getHomepageText = Promise.resolve();
  1058. context.homepage = false;
  1059. } else {
  1060. // Grab the homepage text if it hasn't already been provided.
  1061. getHomepageText = (typeof context.homepage !== 'undefined') ? /* istanbul ignore next */ Promise.resolve() : Promise.all(
  1062. this.options.source.map(source => {
  1063. return glob(source + '/**/' + this.options.homepage);
  1064. })
  1065. ).then(globMatches => {
  1066. for (let files of globMatches) {
  1067. if (files.length) {
  1068. // Read the file contents from the first matched path.
  1069. return fs.readFileAsync(files[0], 'utf8');
  1070. }
  1071. }
  1072. if (this.options.verbose) {
  1073. this.log(' ...no homepage content found in ' + this.options.homepage + '.');
  1074. } else {
  1075. this.log('WARNING: no homepage content found in ' + this.options.homepage + '.');
  1076. }
  1077. return '';
  1078. }).then(homePageText => {
  1079. // Ensure homePageText is a non-false value. And run any results through
  1080. // Markdown.
  1081. context.homepage = homePageText ? md.render(homePageText) : '';
  1082. return Promise.resolve();
  1083. });
  1084. }
  1085. return getHomepageText.then(() => {
  1086. // Render the template and save it to the destination.
  1087. return fs.writeFileAsync(
  1088. path.join(this.options.destination, fileName),
  1089. templateRender(this.templates[templateName], context)
  1090. );
  1091. });
  1092. });
  1093. }
  1094. /**
  1095. * Creates a 2-level hierarchical menu from the style guide.
  1096. *
  1097. * @param {string} pageReference The reference of the root section of the page
  1098. * being built.
  1099. * @returns {Array} An array of menu items that can be used as a template
  1100. * variable.
  1101. */
  1102. createMenu(pageReference) {
  1103. // Helper function that converts a section to a menu item.
  1104. const toMenuItem = function(section) {
  1105. // @TODO: Add an option to "include" the specific properties returned.
  1106. let menuItem = section.toJSON();
  1107. // Remove data we definitely won't need for the menu.
  1108. delete menuItem.markup;
  1109. delete menuItem.modifiers;
  1110. delete menuItem.parameters;
  1111. delete menuItem.source;
  1112. // Mark the current page in the menu.
  1113. menuItem.isActive = (menuItem.reference === pageReference);
  1114. // Mark any "deep" menu items.
  1115. menuItem.isGrandChild = (menuItem.depth > 2);
  1116. return menuItem;
  1117. };
  1118. // Retrieve all the root sections of the style guide.
  1119. return this.styleGuide.sections('x').map(rootSection => {
  1120. let menuItem = toMenuItem(rootSection);
  1121. // Retrieve the child sections for each of the root sections.
  1122. menuItem.children = this.styleGuide.sections(rootSection.reference() + '.*').slice(1).map(toMenuItem);
  1123. // Remove menu items that are deeper than the nav-depth option.
  1124. menuItem.children = menuItem.children.filter(item => {
  1125. return item.depth <= this.options['nav-depth'];
  1126. }, this);
  1127. return menuItem;
  1128. });
  1129. }
  1130. }
  1131. module.exports = KssBuilderBase;