customize

A simple framework to create customizable engines

customize

A simple framework to create customizable engines

Customize is an abstraction of bootprint's the merging-behaviour. It allows you to create your own projects and engines (other than Less and Handlebars) and create overridable configurations for those.

At its core, it uses lodash#mergeWith to merge configurations. It uses a customizer-function that supports promises and custom overrider functions attached to the object.

Engines

Used by

Installation
npm install customize

Usage

The following example should demonstrate the usage of Customize and the files io-helper. Consider the following file tree


├─┬ dir1/
│ ├── a.md
│ └── b.md
├─┬ dir2/
│ └── a.md
├── engine-concat-files.js
├── example-buildConfig.js
├── example1.js
└── example2.js

Creating an engine

The first thing we need, is an engine. For now, we create an engine that just concatenates the contents of all files in a directory. We put this engine into the file engine-concat-files.js

const files = require('customize/helpers-io').files

module.exports = {
  // Optional input schema for engine-configurations
  // If this is present, the JSON will be validated before being passed into "preprocessConfig"
  schema: {
    description: 'Path to a directory containing files',
    type: 'string'
  },

  // Initial configuration when registering the engine.
  defaultConfig: null,

  // Files/Dirs to-be-watched with the default configuration
  defaultWatched: [],

  // This function is called for any `.merge` input.
  // It converts the input into its mergable form
  preprocessConfig: function(config) {
    return files(config)
  },

  // This function is called to determine the files and directories
  // to watch in developmentMode
  watched: function(config) {
    return [
      // The config itself is the directory-path
      config
    ]
  },

  // Runs the engine with a resolved configuration.
  // The config contains no Promises anymore.
  // The function returns an object
  //
  // {
  //    "filename.txt": "file-contents"
  // }
  //
  run: function(config) {
    let result = ''
    Object.keys(config).forEach(filename => {
      result += config[filename].contents + '\n'
    })
    return {
      // Return a file called "concat.txt"
      'concat.txt': result
    }
  }
}
  • The engine provides an empty default configuration. This configuration is used as long as no .merge and .load function is called.

  • The preprocessor of the engine assumes that the input configuration for this engine a path to a directory. It then uses the files io-helper to convert this path into an object of lazy promises.

  • The run-function concatenates the contents of the files. It returns an object

      { "filename.txt": "contents", ... } 

    output file. The module customize-write-files can be used to write such files to disk in a node environment. In order to this to work, the contents must either be a string, a buffer or a readable stream. Strings will be stored in utf-8 encoding.

Loading a configuration

In order to see, how the preprocessor and the files-helper works, we can display the configuration after a merge:

const customize = require('customize')

// Load files from one directory and merge with second
customize()
  .registerEngine('files', require('./engine-concat-files'))
  .merge({
    files: 'dir1'
  })
  .buildConfig()
  .then(result => console.log(result.files))

The example creates a new Customize-instances, registers our engine under the name files and provides the path to a directory as configuration for the files engine (i.e. as property files within the configuration object). It then uses the .buildConfig() function convert all nested promises to a single promise for the whole config. This example prints the following result.

{ 'a.md': { contents: 'First file (from dir1)', path: 'dir1/a.md' },
  'b.md': { contents: 'Second file (from dir1)', path: 'dir1/b.md' } }

We can see that the files-call of the preprocessor converted the directory path into an object containing a one property for each file in the directory.

Running the engine

So far, we have loaded and displayed the preprocessed configuration. Now replace the .buildConfig()-call by .run()

const customize = require('customize')

// Load files from one directory
customize()
  .registerEngine('files', require('./engine-concat-files'))
  .merge({
    files: 'dir1'
  })
  .run()
  .then(result => console.log(result.files))

The engines run()-method will now be executed with the resolved configuration, which yields the following output:

{ 'concat.txt': 'First file (from dir1)\nSecond file (from dir1)\n' }

Merging another configuration

We now have a working customizable configuration. The only thing we have not tried yet is to customize it. We are going to assume that someone, maybe Bob, wants to reuse the configuration for my own purposes, because he really likes it, and it really does exactly what he was looking for. Almost... Except, that the contents of the first file (a.md) needs to be replace by something else. In reality this might be a Handlebars partial to include different contents, or an additional Less-file that changes some styles to follow Bob' company's style-guide.

We can do this, by merging another configuration, but let's have a look at the directory tree before doing this:


├─┬ dir1/
│ ├── a.md
│ └── b.md
├─┬ dir2/
│ └── a.md
├── engine-concat-files.js
├── example-buildConfig.js
├── example1.js
└── example2.js

You can see that the second directory contains a file a.md. We will use this file to replace the file of the first directory.

const customize = require('customize')

// Load files from one directory and merge with second
customize()
  .registerEngine('files', require('./engine-concat-files'))
  .merge({
    files: 'dir1'
  })
  .merge({
    files: 'dir2'
  })
  .run()
  .then(result => console.log(result.files))

There is an additional call to .merge in this code. Its input is also passed to the engine's preprocessor, so now we get two objects containing files and their contents and those are merged by the .mergeWith-function of the lodash library, so that in the above example, the property a.md is replace by the value in the second configuration. So the output of this example is

{ 'concat.txt': 'First file (from dir2)\nSecond file (from dir1)\n' }

Advanced usage

This is the essence of customize. Actually, things are a bit more complicated. A custom overrider ensures (in this order)

  • that nested objects can provide there own overrider function in a _customize_custom_overrider-property,
  • that array-values are concatenated rather than replaced
  • and that promises are correctly merged.

Finally, the .files()-helper does not return the file contents directly. It returns a promise for the file contents. This promise is lazy and only evaluated when the .then()-method is called. And it uses the Customize.leaf() method to attach custom overrider, so that a file-promise replaces its predecessor without .then() being called. This means that files, whose contents is overridden by other files, are not opened for reading.

Application of the principles

Currently, there is only the thought package uses customize, but bootprint uses the same principle.

In thought the .thought/partials directory is included to allow the user to override default Handlebars-partials with custom verison.

In bootprint the user can create packages with Handlebars-partials and Less-definitions, which include and override partials and definitions from other packages.

Troubleshooting

Customize uses the debug module for debug logging. You can use the following channels to enable debugging:

  • DEBUG=customize:versions logs versions of loaded modules (like it was the default in version 1.x)
  • DEBUG=customize:state logs the resolved state after a merge
  • DEBUG=customize:base logs errors and status changes

API-reference

This package will always support the latest version of NodeJS and as well as the current LTS version. In the future, it will not be considered a breaking change to drop support of a pre-LTS version of NodeJS.

The exported module is a function that creates a new empty Customize-instance.

customize

Create a new Customize object with an empty configuration

customize.debugState

For coverage testing: Expose the debugState object so it can be enabled an disabled in testcases

Kind: static property of customize

customize.debug

For coverage testing: Expose the debug object so it can be enabled an disabled in testcases

Kind: static property of customize

customize.Customize : customize

Exposes the constructor of the customize object

Kind: static property of customize

customize.overrider : customOverrider

Custom overrider-function (that is used as customizer in (lodash#merge)[https://lodash.com/docs#merge]

Kind: static property of customize

customize.withParent

Wrap a function so that if it overrides another function, that function will be available as this.parent

Kind: static property of customize
Read only: true
Api: public

Param
fn

customize.leaf ⇒ Promise

Create a promise that is regarded as leaf in the configuration tree. That means, that the overrider is not resolving this promise when overriding values. Promised object values will not be merged but replaced.

Kind: static property of customize
Access: public
Read only: true

Param Type Description
promiseOrValue * a promise or a valude that represents the leaf

customize~Customize

Kind: inner class of customize

new Customize()

This class does the actual work. When calling require('customize')() a new instance of this class is returned with an empty configuration, so new Customize(...) should never be called outside this module config and parentConfig are of the form

{ engine: { config: ..., watched: [ ... ] } }

customize.registerEngine(id, engine)

Register an engine

Kind: instance method of Customize
Access: public

Param Type Description
id string the identifier of the engine. This identifier is also used within the config as key within the configuration object to identify the sub-configuration stored for this engine.
engine object a customize engine that is registered
[engine.defaultConfig] object the default configuration of the engine
engine.preprocessConfig function a preprocessor to convert a merge-configuration to the internal format of the engine
engine.run function the execution function of the engine (the merged config is passed as parameter
engine.run function the execution function of the engine (the merged config is passed as parameter)
[engine.schema] object a JSON-schema to validate the merge-configurations against.

customize.configSchema()

Returns the JSON-schema that configuration objects must match for this configuration. The schema does not contain main description property

Kind: instance method of Customize

customize.merge(config) ⇒ Customize

Creates a new instance of Customize. The configuration values of the current Customize are used as default values and are overridden by the configuration provided as parameter.

Kind: instance method of Customize
Returns: Customize - the new Customize instance
Api: public

Param Type Description
config object configuration overriding the current configuration

customize.load(customizeModule) ⇒ Customize

Inherit configuration config from another module. a Customizer-module usually exports a function(Customize):Customize which in tern calls Customize.merge to create a new Customize instance. This function needs to be passed in here.

A new Customize will be returned that overrides the current configuration with the configuration of the module.

Kind: instance method of Customize
Returns: Customize - the Customize instance returned by the module
Access: public

Param Type Description
customizeModule function that receives a Customize as paramater and returns a Customize with changed configuration.

customize.buildConfig() ⇒ Promise.<object>

Return a promise for the merged configuration. This functions is only needed to inspect intermediate configuration results (i.e. for testing and documentation purposes)

Kind: instance method of Customize
Returns: Promise.<object> - a promise for the whole configuration
Access: public

customize.watched() ⇒ Promise.<object.<Array.<string>>>

Return a promise for the files needing to be watched in watch-mode, indexed by engine.

Kind: instance method of Customize
Returns: Promise.<object.<Array.<string>>> - a promise for the files to be watched.
Access: public

customize.run([options]) ⇒ Promise.<object>

Run each engine with its part of the config.

Kind: instance method of Customize
Returns: Promise.<object> - an object containing on property per registered engine (the key is the engine-id) containing the result of each engine
Access: public

Param Type Description
[options] object optional paramters
[options.onlyEngine] string the name of an engine if only a single engine should be executed

customize~customize() ⇒ Customize

Kind: inner method of customize
Api: public

IO/Helpers

Functions

readFiles(directoryPath, [options])Promise.<object.<string, Promise.<{path:string, contents:string}>>>

An overridable directory which resolves to the contents of all its files (recursively). Returns an undefined value if the directory path is undefined.

files(directoryPath, [options])Promise.<object.<string, Promise.<{path:string, contents:string}>>>

An overridable directory which resolves to the contents of all its files (recursively). Returns an undefined value if the directory path is undefined. The contents of each file is a UTF-8 encoded string.

readFiles(directoryPath, [options]) ⇒ Promise.<object.<string, Promise.<{path:string, contents:string}>>>

An overridable directory which resolves to the contents of all its files (recursively). Returns an undefined value if the directory path is undefined.

Kind: global function
Returns: Promise.<object.<string, Promise.<{path:string, contents:string}>>> - an object containing the relative file-path from the directoryPath as key and the file-path and the file-contents as value

Param Type Description
directoryPath string | null | undefined the path to the directory
[options] object
[options.glob] string an optional glob pattern for filtering files
[options.stream] boolean if set to true, the contents of a file will be a readable stream instead of the actual data.
[options.encoding] string the file is expected to be encoded. This means that the instead of a Buffer, a string is returned. If the 'stream' option is set, the stream's encoding will be set via readable.setEncoding(encoding)

files(directoryPath, [options]) ⇒ Promise.<object.<string, Promise.<{path:string, contents:string}>>>

Deprecated

An overridable directory which resolves to the contents of all its files (recursively). Returns an undefined value if the directory path is undefined. The contents of each file is a UTF-8 encoded string.

Kind: global function
Returns: Promise.<object.<string, Promise.<{path:string, contents:string}>>> - an object containing the relative file-path from the directoryPath as key and the file-path and the file-contents as value

Param Type Description
directoryPath string | null | undefined the path to the directory
[options] object
[options.glob] string an optional glob pattern for filtering files
License

customize is published under the MIT-license.

See LICENSE.md for details.

Release-Notes

For release notes, see CHANGELOG.md

Contributing guidelines

See CONTRIBUTING.md.

Repository

https://github.com/bootprint/bootprint-monorepo.git


公众号

欢迎 JavaScript 爱好者