lib/sandbox.js

'use strict';

// TODO : ESM remove the following lines...
const TemplateOpts = require('./template-options');
const Director = require('./director');
// TODO : ESM uncomment the following lines...
// TODO : import * as TemplateOpts from './template-options.mjs';
// TODO : import * as Director from './director.mjs';

const FUNC_NAME_REGEXP = /[^\da-zA-Z]/g, FUNC_MAX_LOG = 500;

/**
 * A locally sandboxed/isolated environment within a single VM instance
 */
class Sandbox {
  // TODO : ESM use... export class Sandbox {

  /**
   * Compiles a locally sandboxed `async` template rendering function
   * @param {String} [name] The name given to the template (omit to generate via {@link Sandbox.guid})
   * @param {String} [template] The template content that will be used by the renderer. Omit to load the template from cache.
   * @param {TemplateOpts} [compileOpts] The {@link TemplateOpts}
   * @param {Object} [namers] One or more functions responsible for formatting template names into a full path name
   * that is consumable by `read`/`write`.
   * Each function accepts the following arguments:
   * 1. _{Object}_ `namers` An object that contains a property for each available naming function.
   * 1. _{String}_ `partialName` The name of the partial that will be converted into a name suitable for a read operation.
   * 1. _{(TemplateOpts | Function(name:String):*)}_ `optional` Either the {@link TemplateOpts} or a function that takes a single name
   * argument and returns the option value.
   * 1. _{URLSearchParams}_ `[params]` The URLSearchParams that should be used in the converted name.
   * 1. _{Object}_ `storage` The storage object that can contain metadata used by naming operations.
   * 1. _{Boolean}_ `forContent` The flag indicating if the converted name is being used to capture partials.
   * 1. _{String}_ `extension` The file extension override for the converted name (omit to use the default extension set in the options).
   * 1. _{Boolean}_ `forContext` The flag indicating if the converted name is being used to capture context.
   * @param {Function} [namers.namer] The default naming function that will be used.
   * @param {Function} [namers.namerSuper]  The naming function to use when a `operations[].read` function throws an error. The next
   * reader called in the `operations[]` list will use the name generated by this reader.
   * @param {Object[]} [operations] One or more functions and/or objects that will handle render-time read/write operations.
   * @param {Function} [operations[].read] The reader is an `async function` responsible for reading partial template content/modules/etc
   * during render-time when a partial template cannot be found within `includes`. When `options.cacheRawTemplates` is _truthy_ an
   * attempt will be made to add any missing/read partials into `storage.data` in order to prevent unnecessary template partial
   * reads for repeated includes. Read functions should not reference any external scope other than the global object space.
   * The following arguments will be passed:
   * 1. _{String}_ `name` The name of the partial that will be read. The read function may be invoked without a _name_ parameter when
   * the intent is to capture all partials in a single read opteration that will be included.
   * 1. _{String}_ `path` The path to the partial that will be read. The read function may be invoked without a _path_ parameter when
   * the intent is to capture all partials in a single read opteration that will be included.
   * 1. _{String}_ `ext` The path file extension to the partial that will be read. The read function may be invoked without an _ext_
   * parameter when the intent is to capture all partials in a single read opteration that will be included.
   * 1. _{Boolean}_ `forContent` The flag indicating that the read is for content. Otherwise, the read is for rendering functions.
   * 1. _{(TemplateOpts | Function(name:String):*)}_ `optional` Either the {@link TemplateOpts} or a function that takes a single name
   * argument and returns the option value.
   * 1. _{URLSearchParams}_ `[params]` The URLSearchParams that should be used during the read
   * 1. _{Object}_ `storage` The storage object that can contain metadata for read operations and should contain a __data__ object
   * that stores each of the read paratial template content/metadata.
   * 1. _{Function}_ `[formatter]` The function that will format reads/writes during include discovery (if any). The formatting function
   * takes 1 or 2 arguments with the first being the content that will be formatted and the second being `options.readFormatOptions` for
   * reads and `options.writeFormatOptions` for writes.
   * The returned result should be a valid string.
   * 1. _{Boolean}_ `[close]` A flag indicating whether or not any resources used during the read should be closed/cleaned up after the
   * read completes. Closure may be dependent upon the policy set on the options.
   * 1. _{Object}_ `[log]` A logger that can contain functions for each of the following: `error`/`warn`/`info`/`debug`.
   * 
   * Read functions can return the partial template content and/or it can be set on the `storage.data`.
   * Returning `true` will stop any further rendering from processing resulting in the rendering function returning a _blank_ string.
   * @param {Function} [operations[].write] The write function that will be used for writting newly discovered template sources.
   * Accepts the same arguments as `operations[].read` and all _scoped_ functions will be available. Can return a __rendering__
   * function that will prevent further iteration of any subsequent `operations[].write` invocations.
   * @param {Function} [operations[].finish] An `async function` that can perform cleanup tasks for a reader.  Arguments passed are
   * `storage`, `optional` and `log` as described for `operations[].read`.
   * __after rendering has completed__. Arguments passed are `storage`, `optional` and `log` as described for `operations[].read`. All
   * functions defined within `operations[].scopes` will be available by name.
   * @param {Function[]} [operations[].scopes] Zero or more functions that will be in scope when the read function is called.
   * Scoped functions can assit with complex read/write that can benefit from separate supporting functions. For example, `[myFunc(){}]`
   * could be referenced like `async function myReader(){ myFunc(); ... }`.
   * @param {Director} [director] The {@link Director} that will be for extracting {@link Director.directives}
   * @param {Object} [store] The private storage set during compilation that will be passed during naming/reads. Contents should only
   * contain __valid JSON__ properties that can be serialized/deserialized.
   * @param {Object} [store.data] The cached partials that can be included in the template with the name of the template as a
   * property of the object
   * @param {String} [store.data[].name] The name of the partial template
   * @param {String} [store.data[].content] The partial template content
   * @param {Object} [store.data[].params] The parameters that will be added to scope when the template is parsed
   * @param {Object} [log] The log flags that will determine what output will be sent to the `console` (if any) during rendering
   * @param {Boolean} [log.debug] `true` to output `console.debug` level log
   * @param {Boolean} [log.info] `true` to output `console.info` level log
   * @param {Boolean} [log.warn] `true` to output `console.warn` level log
   * @param {Boolean} [log.error] `true` to output `console.error` level log
   * @returns {Function} The rendering `async function` that returns a template result string based upon the provided context.
   * The following arguments apply:
   * 1. _{Object}_ `context` The context JSON that can be used as data during rendering
   * 1. _{TemplateOpts}_ `[renderOptions]` The rendering options that will superceed any __compile-time__ options
   * 1. _{Function}_ `[readFormatter]` The function that will format read partials during include discovery (if any). The
   * formatting function takes 1 or 2 arguments with the first being the content that will be formatted and the second being
   * the `options.readFormatOptions`. The returned result should be a valid string.
   * 1. _{Function}_ `[writeFormatter]` The function that will format written sources during include discovery (if any). The
   * formatting function takes 1 or 2 arguments with the first being the content that will be formatted and the second being
   * the `options.writeFormatOptions`. The returned result should be a valid string.
   * 1. _{Object}_ `[sharedStore]` An object used for _in-memory_ storage space that can be shared between rendering functions.
   * This ensures that updated data within a renderer execution will be retained between rendering calls from the same renderer
   * or different renderers that are passed the same _shared store_.
   */
  static compile(name, template, compileOpts, namers, operations, director, store, log) {
    const copts = compileOpts instanceof TemplateOpts ? compileOpts : new TemplateOpts(compileOpts);
    const named = (name && name.replace(FUNC_NAME_REGEXP, '_')) || `template_${Sandbox.guid(null, false)}`;
    const directives = codedDirectives(director);
    const code = coded(named, template, copts, directives, FUNC_NAME_REGEXP, namers, operations, {
      debug: log && !!log.debug, info: log && !!log.info, warn: log && !!log.warn,
      error: log && !!log.error
    }, null, store);
    return Sandbox.deserialzeBlock(code, named, true);
  }

  /**
   * Deserialzes a function string within a locally sandboxed environment (only global variables are accessible)
   * @param {String} functionString The function string to deserialize
   * @returns {Function|null} The deserialized function
   */
  static deserialzeFunction(functionString) {
    if (functionString && typeof functionString === 'string') {
      try {
        return (new Function(`return ${functionString}`))();
      } catch (err) {
        err.message += ` <- Unable to deserialize function string: `
        + (functionString.length < FUNC_MAX_LOG ? functionString : `${functionString.substring(0, FUNC_MAX_LOG)}...`);
        throw err;
      }
    }
  }

  /**
   * Deserialzes a code block iwthin a locally sandboxed environment (only global variables are accessible)
   * @param {(String | Function)} block The code block to deserialize
   * @param {String} [name] A name that will be given to the function
   * @param {Boolean} [isAsync] `true` when the function is `async`
   * @returns {(Function | undefined)} The deserialized function
   */
  static deserialzeBlock(block, name, isAsync) {
    const type = typeof block;
    if (block && (type === 'string' || type === 'function')) {
      if (type === 'function') { // convert function into iife formatted function block
        block = `return (${block.toString()})()`;
      }
      const named = name && name.length ? ` ${name.replace(FUNC_NAME_REGEXP, '_')}` : '';
      // pass require (when present) since it may be hidden from the block scope
      const rqr = typeof require !== 'undefined' ? require : undefined;
      const rqrd = rqr ? `const require=arguments[0];` : '';
      const code = `${rqrd}return ${isAsync ? 'async ' : ''}function${named}(){ ${block}; }`;
      try {
        //const func = new Function(code);
        //if (named) Object.defineProperty(rtn.func, 'name', { value: named });
        //return named ? { [named](...args) { return func(...args); } }[named] : func;
        // could also use ES2017 new AsyncFunction(...)
        return (new Function(code))(rqr);
      } catch (err) {
        err.message += ` <- Unable to deserialize code on name: "${name || ''}" for:\n`
        + (code.length < FUNC_MAX_LOG ? code : `${code.substring(0, FUNC_MAX_LOG)}...`);
        throw err;
      }
    }
  }

  /**
   * Serialzes a function
   * @param {Function} func The function to serialize
   * @returns {String|null} The serialized function
   */
  static serialzeFunction(func) {
    return func && typeof func === 'function' ? func.toString() : null;
  }

  /**
  * Generates a GUID or formats an existing `value`
  * @param {String} [value] when present, will format the value by add any missing hyphens (if `hyphenate=true`)
  * instead of generating a new value
  * @param {Boolean} [hyphenate=true] true to include hyphens in generated result
  * @returns {String} the generated GUID
  */
  static guid(value, hyphenate = true) {
    const hyp = hyphenate ? '-' : '';
    if (value) return hyphenate ? value.replace(/(.{8})-?(.{4})-?(.{4})-?(.{4})-?(.{12})/gi, `$1${hyp}$2${hyp}$3${hyp}$4${hyp}$5`) : value;
    return `xxxxxxxx${hyp}xxxx${hyp}4xxx${hyp}yxxx${hyp}xxxxxxxxxxxx`.replace(/[xy]/g, function (c) {
      var r = Math.random() * 16 | 0, v = c == 'x' ? r : (r & 0x3 | 0x8);
      return v.toString(16);
    });
  }

  /**
   * @returns {Object} The global object. Typically, `window` when ran within a browser or `global` when ran on a server
   */
  static get global() {
    return (function globalize(){ return this; })();
  }
}

// TODO : ESM remove the following lines...
module.exports = Sandbox;

/**
 * Manufactures directives/functions that will be available to template lierals during rendering
 * @private
 * @param {Director} [director] The {@link Director} that will be for extracting {@link Director.directives}
 * @returns {Object} The `{ names:String, code:String }` where `names` is a string representation of the directive
 * names (in array format) and `code` represents the commulative coded functions
 */
function codedDirectives(director) {
  if (!(director instanceof Director)) {
    throw new Error(`Expected ${Director.name}, but found ${director}`);
  }
  const directives = director.directives;
  const rtn = { names: '', code: '' };
  for (let drv of directives) {
    rtn.code += `${drv.code.toString()}`;
    rtn.names += (rtn.names ? ',' : '') + drv.name;
  }
  rtn.names = `[${rtn.names}]`;
  return rtn;
}

/**
 * Generates a locally sandboxed environment compilation for template rendering
 * @private
 * @param {String} name A sanitized name given to the template
 * @param {String} [template] The template content that will be used by the renderer. Omit to load the template from cache.
 * @param {Object} includes The cached partials that can be included in the template with the name of
 * the template as a property of the object
 * @param {String} includes[].name The name of the partial template
 * @param {String} includes[].content The partial template content
 * @param {TemplateOpts} compileOpts The {@link TemplateOpts}
 * @param {Object} directives A return object from {@link codedDirectives}
 * @param {RegExp} nameRegExp The regular expression that will replace invalid characters when naming rendering functions
 * @param {Object} [namers] One or more functions responsible for formatting template names into a full path name
 * that is consumable by `read`/`write`.
 * Each function accepts the following arguments:
 * 1. _{Object}_ `namers` An object that contains a property for each available naming function.
 * 1. _{String}_ `partialName` The name of the partial that will be converted into a name suitable for a read operation.
 * 1. _{String}_ `path` The full name/path for the partial returned from a __namer__ function.
 * 1. _{(TemplateOpts | Function(name:String):*)}_ `optional` Either the {@link TemplateOpts} or a function that takes a single name
 * argument and returns the option value.
 * 1. _{URLSearchParams}_ `[params]` The URLSearchParams that should be used in the converted name.
 * 1. _{Object}_ `storage` The storage object that can contain metadata used by naming operations.
 * 1. _{Boolean}_ `forContent` The flag indicating if the converted name is being used to capture partials.
 * 1. _{String}_ `extension` The file extension override for the converted name (omit to use the default extension set in the options).
 * 1. _{Boolean}_ `forContext` The flag indicating if the converted name is being used to capture context.
 * @param {Function} [namers.namer] The default naming function that will be used.
 * @param {Function} [namers.namerSuper]  The naming function to use when a `operations[].read` function throws an error. The next
 * reader called in the `operations[]` list will use the name generated by this reader.
 * @param {Object[]} [operations] One or more functions and/or objects that will handle render-time read/write operations.
 * @param {Function} [operations[].read] The reader is an `async function` responsible for reading partial template content/modules/etc
 * during render-time when a partial template cannot be found within `includes`. When `options.cacheRawTemplates` is _truthy_ an
 * attempt will be made to add any missing/read partials into `storage.data` in order to prevent unnecessary template partial
 * reads for repeated includes. Read functions should not reference any external scope other than the global object space.
 * The following arguments will be passed:
 * 1. _{String}_ `name` The name of the partial that will be read. The read function may be invoked without a _name_ parameter when
 * the intent is to capture all partials in a single read opteration that will be included.
 * 1. _{String}_ `path` The path to the partial that will be read. The read function may be invoked without a _path_ parameter when
 * the intent is to capture all partials in a single read opteration that will be included.
 * 1. _{String}_ `ext` The path file extension to the partial that will be read. The read function may be invoked without an _ext_
 * parameter when the intent is to capture all partials in a single read opteration that will be included.
 * 1. _{Boolean}_ `forContent` The flag indicating that the read is for content. Otherwise, the read is for rendering functions.
 * 1. _{(TemplateOpts | Function(name:String):*)}_ `optional` Either the {@link TemplateOpts} or a function that takes a single name
 * argument and returns the option value.
 * 1. _{URLSearchParams}_ `[params]` The URLSearchParams that should be used during the read
 * 1. _{Object}_ `storage` The storage object that can contain metadata for read operations and should contain a __data__ object
 * that stores each of the read paratial template content/metadata.
 * 1. _{Function}_ `[formatter]` The function that will format reads/writes during include discovery (if any). The formatting function
 * takes 1 or 2 arguments with the first being the content that will be formatted and the second being `options.readFormatOptions` for
 * reads and `options.writeFormatOptions` for writes.
 * The returned result should be a valid string.
 * 1. _{Boolean}_ `[close]` A flag indicating whether or not any resources used during the read should be closed/cleaned up after the
 * read completes. Closure may be dependent upon the policy set on the options.
 * 1. _{Object}_ `[log]` A logger that can contain functions for each of the following: `error`/`warn`/`info`/`debug`.
 * 
 * Read functions can return the partial template content and/or it can be set on the `storage.data`.
 * Returning `true` will stop any further rendering from processing resulting in the rendering function returning a _blank_ string.
 * @param {Function} [operations[].write] The write function that will be used for writting newly discovered template sources.
 * Accepts the same arguments as `operations[].read` and all _scoped_ functions will be available. Can return a __rendering__
 * function that will prevent further iteration of any subsequent `operations[].write` invocations.
 * @param {Function} [operations[].finish] An `async function` that can perform cleanup tasks for a reader.  Arguments passed are
 * `storage`, `optional` and `log` as described for `operations[].read`.
 * __after rendering has completed__. Arguments passed are `storage`, `optional` and `log` as described for `operations[].read`. All
 * functions defined within `operations[].scopes` will be available by name.
 * @param {Function[]} [operations[].scopes] Zero or more functions that will be in scope when the read function is called.
 * Scoped functions can assit with complex reads/writes that can benefit from separate supporting functions. For example, `[myFunc(){}]`
 * could be referenced like `async function myReader(){ myFunc(); ... }`.
 * @param {Object} [log] The log flags that will determine what output will be sent to the `console` (if any) during rendering
 * @param {Boolean} [log.debug] `true` to output `console.debug` level log
 * @param {Boolean} [log.info] `true` to output `console.info` level log
 * @param {Boolean} [log.warn] `true` to output `console.warn` level log
 * @param {Boolean} [log.error] `true` to output `console.error` level log
 * @param {Object} [params] The parameters that will be available via the `options.includesParametersName`
 * alias within the scope of the included template
 * @param {Object} [store] The private storage that is passed during naming/reads. Contents should only contain __valid JSON__ properties that
 * can be serialized/deserialized.
 * @param {Object} [storeMeta] The __private__ metadata pertaining to the storage being used
 * @param {Boolean} [storeMeta.hasNoNameInit] `true` when the templates have been initialized meetting the criteria described in {@link _readHandler}
 * @param {Boolean} [storeMeta.finished] `true` when all of the available `operations[].finish` functions have completed execution
 * @param {Object} [metadata] The metadata that describes the template being coded (exposed to the tempalte scope)
 * @param {String} [metadata.name] The name of the template that the current template is being coded for
 * @param {Object} [metadata.parent] The metadata of the template parent to the current one being coded
 * @returns {String} A coded rendering representation to be used in a function body by a template
 * engine. Assumes `arguments[0]` is a `context` object, `arguments[1]` is the rendering options object and `arguments[2]` is a function that
 * will format written sources during include discovery (if any). The formatting function takes 1 or 2 arguments with the first being the content
 * that will be formatted and the second being `options.readFormatOptions` for reads or `options.writeFormatOptions` for writes. 
 */
function coded(name, template, compileOpts, directives, nameRegExp, namers, operations, log, params, store, storeMeta, metadata) {
  const varName = typeof compileOpts.varName === 'string' ? compileOpts.varName : '';
  const tmpl = typeof template === 'string' ? template : `\${ await include\`${optional('defaultTemplateName', compileOpts, {})}\` }`;
  const strFn = (key, val) => val instanceof RegExp ? { class: RegExp.name, source: val.source, flags: val.flags } : val;
  const meta = `const metadata=Object.freeze(${JSON.stringify({ name, parent: metadata })});`;
  const debug = optional('debugger', compileOpts, {}) ? 'debugger;' : '';
  const debugx = log && log.debug ? typeof log.debug === 'function' ? console.debug === log.debug ? 'console.debug' : log.debug.toString() : 'console.debug' : false;
  const infox = log && log.info ? typeof log.info === 'function' ? console.info === log.info ? 'console.info' : log.info.toString() : 'console.info' : false;
  const warnx = log && log.warn ? typeof log.warn === 'function' ? console.warn === log.warn ? 'console.warn' : log.warn.toString() : 'console.warn' : false;
  const errorx = log && log.error ? typeof log.error === 'function' ? console.error === log.error ? 'console.error' : log.error.toString() : 'console.error' : false;
  const logx = `const log={debug:${debugx},info:${infox},warn:${warnx},error:${errorx}};`;
  let nmrs = typeof namers === 'object' ? namers : {}, namerAdded, nmro, namex = 'const namers=Object.freeze({';
  for (let nmr in nmrs) {
    if (!nmrs.hasOwnProperty(nmr)) continue;
    if (typeof nmrs[nmr] === 'function') {
      nmro = `${nmr}:${nmrs[nmr].toString()}`;
      namex += `${namerAdded ? ',' : ''}${nmro}`;
      namerAdded = true;
    } else throw new Error(`Unable to add naming function: ${nmrs[nmr]}`);
  }
  namex += '});';
  let ops = Array.isArray(operations) ? operations : [operations], opAdded, opx = 'const operations=Object.freeze([';
  const addOp = (op, idx) => {
    const rdf = typeof op.read === 'function' ? op.read : null;
    const wrf = typeof op.write === 'function' ? op.write : function noopWrite() { return Promise.resolve(); };
    const fsh = typeof op.finish === 'function' ? op.finish : null;
    if (!rdf) throw new Error(`"read" operation function at index ${idx} not present for "${op.read}"`);
    const scp = op && Array.isArray(op.scopes) && op.scopes.length ? op.scopes : null;
    let opo = '', opScoped = '';
    if (scp) {
      const opNames = [{ name: 'read', func: rdf, num: 1 }, { name: 'write', func: wrf, num: 2 }];
      if (fsh) opNames.push({ name: 'finish', func: fsh, num: 3 });
      for (let sfunc of scp) {
        if (typeof sfunc === 'function' && sfunc.name) {
          opScoped += `let ${sfunc.name}=${sfunc.toString()};`;
          opScoped += `{let fn=${sfunc.name};for(let sfn of thiz.scopes){if(sfn.name === '${sfunc.name}'){fn=null;break;}}if(fn){thiz.scopes.push(${sfunc.name});}}`
        }
      }
      opo += `scopedOp:function(type,thiz,args){${opScoped} if(typeof type === 'undefined'){return;} `;
      opo += `const _op=type === 1 ? ${rdf.toString()} : type === 2 ? ${wrf.toString()} : ${fsh ? fsh.toString() : '()=>null'};`;
      opo += `return args && args.length ? _op.apply(undefined, args) : _op;}`;
      for (let ono of opNames) {
        opo += `,get ${ono.name}(){const thiz=this;const ${ono.name}=function ${ono.func.name || ''}(){`;
        opo += `return thiz.scopedOp(${ono.num},thiz,arguments);};`;
        opo += `${ono.name}.toString=() => ${ono.name}().toString();`;
        opo += `return ${ono.name};}`;
      }
      opo += `,scopes:[]`;
    } else {
      opo = `read:${rdf.toString()}`;
      opo += `,write:${wrf.toString()}`;
      if (fsh) opo += `,finish:${fsh.toString()}`;
    }
    opx += `${opAdded ? ',' : ''}Object.freeze({${opo}})`;
    opAdded = true;
  };
  let opi = -1;
  for (let op of ops) addOp(op, ++opi);
  opx += ']);';
  const nmreg = `const nameRegExp=/${nameRegExp.source}/${nameRegExp.flags || ''};`;
  const coptx = `const compileOpts=${JSON.stringify(compileOpts, strFn)};`;
  const roptx = `const renderOpts=typeof arguments[1] === 'object' ? arguments[1] : {};`;
  const frmtx = `const readFormatter=typeof arguments[2] === 'function' ? arguments[2] : null;const writeFormatter=typeof arguments[3] === 'function' ? arguments[3] : null;`;
  const optsx = `const optional=${optional.toString()};`;
  const storex = `const store=${store ? JSON.stringify(store) : '{}'};const storeDta=store.data;store.data=arguments[4] || storeDta || {};`;
  const storedx = `for (let nm in (storeDta !== store.data && store.data)){if(store.data.hasOwnProperty(nm))continue;store.data[nm]=storeDta.data[nm];};`;
  const storemx = `const storeMeta=${storeMeta ? JSON.stringify(storeMeta) : '{}'};`
  const varx = `const varName='${varName}';`;
  const dirsx = `const directives=${JSON.stringify(directives)};`;
  const sandx = `const renderContent=${renderContent.toString()};const renderRead=${renderRead.toString()};const coded=${coded.toString()};`;
  const inclx = `_readHandler=${_readHandler.toString()};include=${include.toString()};if (await _readHandler()) { return ''; };`;
  // read/load the primary template when not passed into coded and/or context JSON when not passed into the renderer
  const tempx = `ctx=typeof arguments[0] === 'object' ? arguments[0] : null;`;
  const itx = `${tempx}{ const cnm=(!ctx || typeof ctx !== 'object') && optional('defaultContextName');ctx=cnm ? await renderRead(cnm, true) : ctx; } `;
  const ctx = `const ${varName}=ctx;const context=${varName};ctx=undefined;`;
  const prm = `const ${optional('includesParametersName', compileOpts, {}) || 'params'}=${JSON.stringify(params)};`;
  // privately scoped parameters are isolated in a separate code block to restrict access to just the include
  // function and other private values
  const incl = `let include,ctx,_readHandler; { ${logx}${namex}${opx} { ${nmreg}${coptx}${roptx}${frmtx}${optsx}${storex}${storedx}${storemx}${varx}${dirsx}${sandx}${inclx}${itx} } }`;
  const fnshx = 'await _readHandler(true);';
  const srcurl = optional('useSourceURL', compileOpts, {}) ? `\n//# sourceURL=${name}.js` : '';
  // the context object is contained in a separate code block in order to isolate it from access within directives
  return `${meta}${directives.code}; { ${incl}${ctx}${prm}${debug}const result=\`${tmpl}\`;${fnshx}return result; }${srcurl}\n`;
}

/**
 * Runs through all of the operations and runs any async `finish` tasks
 * __Assumes the following variables are within scope:__
 * - `metadata` - The metadata for exectution
 * - `compileOpts` - The compile options
 * - `renderOpts` - The rendering options
 * - `readFormatter` - The rendering formatting function that will be used when reading partial template content
 * - `writeFormatter` - The rendering formatting function that will be used when writting compiled sources
 * - `optional` - The {@link optional} function
 * - `store` - Miscellaneous storage container
 * - `storeMeta` - The storage metadata
 * - `operations` - One or more async functions that will read partial templates and write included/compiled rendering functions
 * - `log` - The log functions
 * @private
 * @ignore
 * @param {Boolean} [isFinish] `true` when running any `reader.finish` tasks or any other value when calling `reader.read` without a __name__
 * argument on each reader. Once all read operations have been invoked without a __name__ argument, `storeMeta.hasNoNameInit` will be
 * set to __true__.
 * @returns {Boolean} `true` when a `reader.read` returns `true` indicating that the rendering should not continue. `false` for all
 * other scenarios.
 */
async function _readHandler(isFinish) {
  if (storeMeta.stopped) return;
  if (isFinish) {
    if (!metadata.name || storeMeta.finished) return;
    if (metadata.name !== optional('defaultTemplateName')) return;
  }
  for (let op of operations) {
    if (isFinish) {
      if (op.finish) await op.finish(store, optional, log);
    } else {
      if (!storeMeta.hasNoNameInit) {
        try {
          if ((await op.read(null, null, null, true, optional, null, store, readFormatter, true, log)) === true) {
            return (storeMeta.stopped = true);
          }
        } catch (err) {
          err.message += ` <- Failed to initiate reader "${op.read.name}"`;
          throw err;
        }
      }
    }
  }
  if (isFinish) {
    storeMeta.finished = true;
  } else storeMeta.hasNoNameInit = true; // should only int 1x
  return false;
}

/**
 * Returns either the `renderOpts` or the `compileOpts` option value (in that order of precedence). Also converts `RegExp` constructs
 * into regular expressions. For example, `{ class: 'RegExp', source: "a|b", flags: "i" }` will be converted into `/a|b/i`.
 * __Assumes the following variables are within scope:__
 * - `compileOpts` - The compile options
 * - `renderOpts` - The rendering options
 * @private
 * @ignore
 * @param {String} name The name of the option to check for
 * @param {Object} [copts] Compile options override of an in-scope `compileOpts`
 * @param {Object} [ropts] Render options override of an in-scope `renderOpts`
 * @returns {*} The value of the option
 */
function optional(name, copts, ropts) {
  copts = copts || compileOpts;
  ropts = ropts || renderOpts;
  const opts = ropts.hasOwnProperty(name) ? ropts : copts;
  if (opts[name] && opts[name].class === RegExp.name) {
    opts[name] = new RegExp(opts[name].source, opts[name].flags);
  }
  return opts[name];
}

/**
 * Compiles and renders template content at render-time
 * __Assumes the following variables are within scope:__
 * - `metadata` - The metadata for exectution
 * - `compileOpts` - The compile options
 * - `renderOpts` - The rendering options
 * - `readFormatter` - The rendering formatting function that will be used when reading partial template content
 * - `writeFormatter` - The rendering formatting function that will be used when writting compiled sources
 * - `optional` - The {@link optional} function
 * - `store` - Miscellaneous storage container
 * - `storeMeta` - The storage metadata
 * - `context` - The context object passed into the rendering function
 * - `directives` - The directive/helper functions used in the template (from {@link Director.directives})
 * - `operations` - One or more async functions that will read partial templates and write included/compiled rendering functions
 * - `namers` - An object that contains one or more functions that will compose names that will be passed into read/write operations
 * - `nameRegExp` - The regular expression that will replace invalid characters when naming rendering functions
 * - `coded` - The {@link coded} function
 * - `log` - The log functions
 * @private
 * @ignore
 * @param {String} name The template name
 * @param {String} path The complete path name
 * @param {String} [ext] The path extension
 * @param {String} content The raw, uncompiled template content
 * @param {Object} [params] The parameters that will be available via the `options.includesParametersName`
 * alias within the scope of the included template
 * @param {Boolean} [fromCache] `true` when the content is coming from cache
 * @returns {String} The rendered template content
 */
async function renderContent(name, path, ext, content, params, fromCache) {
  if (!content) content = optional('defaultPartialContent');
  // new Function restricts access to closures (other than globals)
  const block = coded(path, content, compileOpts, directives, nameRegExp, namers, operations, log, params, store, storeMeta, metadata);
  // pass require (when present) since it may be hidden from the block scope
  const rqr = typeof require !== 'undefined' ? require : undefined;
  // optionally "require" can be passed when available
  const rqrd = rqr ? `const require=arguments[0];` : '';
  const named = path.replace(nameRegExp, '_');
  let renderer = new Function(`${rqrd}return async function ${named}(){ ${block}; }`)(rqr);
  if (!fromCache && ext) {
    const policy = optional('renderTimePolicy');
    if (policy.includes('write')) {
      const isClosePolicy = policy.includes('close'), srcPath = path.replace(new RegExp(`.${ext}$`), '.js');
      let error, nerr, wcnt = 0, writeRenderer;
      for (let op of operations) {
        wcnt++;
        if (!op.write) continue;
        try {
          writeRenderer = await op.write(name, srcPath, 'js', false, optional, params, store, renderer, writeFormatter, isClosePolicy, log);
          if (typeof writeRenderer === 'function') {
            renderer = writeRenderer;
            break;
          }
        } catch (err) {
          nerr = new Error(err.constructor.name !== 'Error' ? `${err.constructor.name || 'SandboxError'}:` : '');
          nerr.code = err.code;
          nerr.stack = `${err.message}${error && error.message ? ` <- ${error.message}` : ''}`
          + (wcnt >= operations.length ? ` <- Unable to write content via "${op.write.name}" for included template "${name}"`
          + ` using policy "${policy}" (render-time read)`
          : '') + `\nWRITER #${wcnt} of ${operations.length}: ${op.write.name}\n${err.stack}${error ? `\n${error.stack}` : ''}`;
          error = nerr;
        }
      }
      if (error) throw error;
    }
  }
  return renderer(context, renderOpts, readFormatter, writeFormatter);
}

/**
 * Reads a template __content__ or a template __context__ at render-time
 * __Assumes the following variables are within scope:__
 * - `metadata` - The metadata for exectution
 * - `compileOpts` - The compile options
 * - `renderOpts` - The rendering options
 * - `readFormatter` - The rendering formatting function that will be used when reading partial template content
 * - `writeFormatter` - The rendering formatting function that will be used when writting compiled sources
 * - `optional` - The {@link optional} function
 * - `store` - Miscellaneous storage container
 * - `storeMeta` - The storage metadata
 * - `context` - The context object passed into the rendering function
 * - `directives` - The directive/helper functions used in the template (from {@link Director.directives})
 * - `operations` - One or more async functions that will read partial templates and write included/compiled rendering functions
 * - `namers` - An object that contains one or more functions that will compose names that will be passed into read/write operations
 * - `nameRegExp` - The regular expression that will replace invalid characters when naming rendering functions
 * - `coded` - The {@link coded} function
 * - `log` - The log functions
 * - `renderContent` - The {@link renderContent} function
 * @private
 * @param {String} name The template or context name
 * @param {Boolean} [forContext] `true` to read a template __context__ instead of the default read for a template
 * __content__
 * @param {URLSearchParams} [sprms] The key/value parameters to pass into the `operations[].read`
 * @param {Object} [iprms] The parameters that will be available via the `options.includesParametersName`
 * alias within the scope of the included template
 * @param {Boolean} [fromExpression] `true` when the read is from an interpolated expression
 * @returns {(String | Object)} Either the template __content__ or the JSON __context__
 */
async function renderRead(name, forContext, sprms, iprms, fromExpression) {
  let rtn, path;
  try {
    path = await namers.namer(namers, name, optional, sprms, store, true, null, forContext);
  } catch (err) {
    const error = new Error(`${err.message} <- Unable to extract a name for con${forContext ? 'text' : 'tent'} via`
    + ` NAMER: "${namers.namer.name}" for include template @ "${path || name}" (render-time read)`);
    error.stack = err.stack;
    throw error;
  }
  let ext = path && path.match(/^.*?\.([^/.]+)$/), fromCache;
  ext = ext && ext[1];
  if (store.data[path] && store.data[path].hasOwnProperty('content')) {
    if (log && log.info) log.info(`RENDER: ✔️ Found registered "${path}" in memory during pre-read`);
    rtn = store.data[path].content;
    fromCache = true;
  } else if (operations.length && optional('renderTimePolicy') !== 'none') {
    let error, nerr, rcnt = 0, fnames, policy = optional('renderTimePolicy'), close = policy.includes('close'), rtnType;
    for (let op of operations) {
      try {
        rcnt++;
        rtn = await op.read(name, path, ext ? ext : undefined, true, optional, sprms, store, readFormatter, close, log);
        rtnType = typeof rtn;
        if (rtnType === 'string')  break;
        else if (rtnType === 'object' && rtn.hasOwnProperty('content')) {
          rtnType = rtn.content && typeof rtn.content;
          if (rtnType === 'string' || rtnType === 'object') {
            rtn = rtn.content;
            break;
          }
        } else if (store.data[path]) {
          rtnType = store.data[path].content && typeof store.data[path].content;
          if (rtnType === 'string' || rtnType === 'object') {
            rtn = store.data[path].content;
            break;
          }
        }
        if (rcnt >= operations.length) throw new Error(`Exhausted all ${operations.length} available readers`);
        throw new Error(`Reader "${op.read.name}" returned "${typeof rtn}" rather than an expected content "string"`);
      } catch (err) {
        if (fnames) fnames.push(path);
        else fnames = [path];
        if (typeof namers.namerSuper === 'function') {
          try {
            path = await namers.namerSuper(namers, name, optional, sprms, store, true, null, forContext, path);
            if (store.data[path] && typeof store.data[path].content === 'string') {
              rtn = store.data[path].content;
              break;
            }
          } catch (nerr) {
            const error = new Error(`${nerr.message} <- Unable to extract a name for con${forContext ? 'text' : 'tent'} via`
            + ` READ ERROR NAMER: "${namers.namerSuper.name}" for include template @ "${path || name}" (render-time read)`);
            error.stack = nerr.stack;
            throw error;
          }
        }
        nerr = new Error(err.constructor.name !== 'Error' ? `${err.constructor.name || 'SandboxError'}:` : '');
        nerr.code = err.code;
        nerr.stack = `${err.message}${error && error.message ? ` <- ${error.message}` : ''}`
        + (rcnt >= operations.length ? ` <- Unable to read content via "${op.read.name}" for included template "${name}" @`
        + ` reader names: "${fnames.join('" >> "')}"${metadata.parent ? ` under parent "${metadata.parent.name}"` : ''}`
        + ` using policy "${policy}"${err instanceof TypeError ? 
          '. Ensure that a proper URL/path option has been set' : ''} (render-time read)`
        : '') + `\nREADER #${rcnt} of ${operations.length}: ${op.read.name}\n${err.stack}${error ? `\n${error.stack}` : ''}`;
        error = nerr;
      }
      if (error && rcnt >= operations.length) throw error;
    }
    if (typeof rtn === 'string' && optional('cacheRawTemplates')) {
      if (log && log.info) log.info(`RENDER: 🏧 Registering "${path}" in memory during rendering`);
      store.data[path] = { name: path, shortName: name, content: rtn, renderTime: true };
    }
  } else if (sprms) {
    const cause = operations.length ? '"options.renderTimePolicy" is set to "none"' : 'no reader function(s) have been defined';
    const detail = `Read refresh required since parameters have been passed to include, but ${cause}. Parameters: ${sprms.toString()} `;
    throw new Error(`Cannot include template @ ${path} (${fromExpression ? 'expression' : 'string'} literal). ${detail}`);
  } else throw new Error(`Cannot find included template @ ${path} (${fromExpression ? 'expression' : 'string'} literal)`);
  if (forContext) {
    if (typeof rtn !== 'object') {
      try {
        rtn = JSON.parse(rtn);
      } catch (err) {
        err.message += ` <- Unable to parse JSON context @ ${path} (render-time read)`;
        throw err;
      }
    }
  } else {
    try {
      rtn = await renderContent(name, path, ext, rtn, iprms, fromCache);
    } catch (err) {
      err.message += ` <- Unable to include template @ ${path} (render-time read)`;
      throw err;
    }
  }
  return rtn;
}

/**
 * Template literal tag that will include partials during the rendering phase.
 * __Assumes the following variables are within scope:__
 * - `metadata` - The metadata for exectution
 * - `compileOpts` - The compile options
 * - `renderOpts` - The rendering options
 * - `readFormatter` - The rendering formatting function that will be used when reading partial template content
 * - `writeFormatter` - The rendering formatting function that will be used when writting compiled sources
 * - `optional` - The {@link optional} function
 * - `store` - Miscellaneous storage container
 * - `storeMeta` - The storage metadata
 * - `varName` - The name of the context variable
 * - `context` - The context object passed into the rendering function
 * - `directives` - The directive/helper functions used in the template (from {@link Director.directives})
 * - `operations` - One or more async functions that will read partial templates and write included/compiled rendering functions
 * - `namers` - An object that contains one or more functions that will compose names that will be passed into read/write operations
 * - `nameRegExp` - The regular expression that will replace invalid characters when naming rendering functions
 * - `coded` - The {@link coded} function
 * - `log` - The log functions
 * - `renderContent` - The {@link renderContent} function
 * - `renderRead` - The {@link renderRead} function
 * @private
 * @param {String[]} strs The string passed into template literal tag that contains the partial template name to include
 * @param  {Array} exps The expressions passed into template literal tag. Each expression can be one of the following:
 * - `String` - A partial template name to include
 * - `Object` - An object that contains properties that will be passed into the read function for each name that appear
 * __at__ or __before__ the same index as the object parameter expression. Parameters are __not__ cumulative. For example,
 * `name1${ { param1: 1 } }name2${ { param2: 2 } }` would imply `name1` would have `param1` while `name2` would __only__
 * have `param2`, __not__ `param1`.
 */
async function include(strs, ...exps) {
  let rtn = '';
  for (let i = 0, ln = Math.max(strs.length, exps.length), str, exp, names, ni, sprms, iprms; i < ln; i++) {
    str = strs[i] && strs[i].trim();
    exp = exps[i];
    if (str && exp instanceof URLSearchParams) {
      if (operations.length && optional('renderTimePolicy') !== 'none') sprms = exp;
      exp = null;
    } else if (str && exp && typeof exp === 'object') {
      iprms = exp;
      exp = null;
    }
    if (!str && !exp && !sprms && !iprms) continue;
    names = str && exp ? [str, exp] : str ? [str] : exp ? [exp] : null;
    if (names) {
      ni = -1;
      for (let name of names) {
        ni++;
        rtn += await renderRead(name, false, sprms, iprms, ni > 0);
      }
    }
    sprms = iprms = null; // parameters do not carry over from one string/expression to the next
  }
  return rtn;
}

1.0.0 (2019-12-16)

Full Changelog