lib/cachier-files.js

'use strict';

const TemplateFileOpts = require('./template-file-options');
const Cachier = require('./cachier');
const Sandbox = require('./sandbox');
const Os = require('os');
const Fs = require('fs');
const Path = require('path');
// TODO : ESM uncomment the following lines...
// TODO : import * as TemplateFileOpts from './template-file-options.mjs';
// TODO : import * as Cachier from './cachier.mjs';
// TODO : import * as Sandbox from './sandbox.mjs';
// TODO : import * as Os from 'os';
// TODO : import * as Fs from 'fs';
// TODO : import * as Path from 'path';

const Fsp = Fs.promises;

/**
 * Node.js [file]{@link https://nodejs.org/api/fs.html} persistence manager that uses the file system for improved debugging/caching
 * capabilities
 */
class CachierFiles extends Cachier {
// TODO : ESM use... export class CachierFiles extends Cachier {

  /**
   * Constructor
   * @param {TemplateFileOpts} [opts] The {@link TemplateFileOpts}
   * @param {Function} [readFormatter] The `function(string, readFormatOptions)` that will return a formatted string for __reading__
   * data using the `options.readFormatOptions` from {@link TemplateOpts} as the formatting options. Typically reads are for __HTML__
   * _minification_ and/or _beautifying_. __NOTE: Use with caution as syntax errors may result depending on the formatter used and the
   * complexity of the data being formatted!__ 
   * @param {Function} [writeFormatter] The `function(string, writeFormatOptions)` that will return a formatted string for __writting__
   * data using the `options.writeFormatOptions` from {@link TemplateOpts} as the formatting options. Typically reads are for __JS__
   * _minification_ and/or _beautifying_. __NOTE: Use with caution as syntax errors may result depending on the formatter used and the
   * complexity of the data being formatted!__ 
   * @param {Object} [log] The log for handling logging output
   * @param {Function} [log.debug] A function that will accept __debug__ level logging messages (i.e. `debug('some message to log')`)
   * @param {Function} [log.info] A function that will accept __info__ level logging messages (i.e. `info('some message to log')`)
   * @param {Function} [log.warn] A function that will accept __warning__ level logging messages (i.e. `warn('some message to log')`)
   * @param {Function} [log.error] A function that will accept __error__ level logging messages (i.e. `error('some message to log')`)
   */
  constructor(opts, readFormatter, writeFormatter, log) {
    super(opts instanceof TemplateFileOpts ? opts : new TemplateFileOpts(opts), readFormatter, writeFormatter, log);
  }

  /**
   * Registers and caches the tempalte, one or more partials and/or context within the internal file system. Also creates any missing
   * directories/sub-directories within the {@link TemplateFileOpts} `outputPath` directory from `partialsPath`
   * sub-directories.
   * @override
   * @param {Boolean} [read] When `true`, an attempt will be made to also _read_ any partials using option parameter
   * @param {Object[]} [data] The template, partials and/or context to register.
   * @param {String} partials[].name The name that uniquely identifies the template, partial or context
   * @param {String} [partials[].content] The raw content that will be registered. Omit when `read === true` to read content from cache.
   * @param {URLSearchParams} [partials[].params] The `URLSearchParams` that will be passed during the content `read`
   * (__ignored when `content` is specified__).
   * @param {String} [partials[].extension] Optional override for a file extension designated for a template, partial or context.
   * @param {Boolean} [read] When `true`, an attempt will be made to also {@link Cachier.read} the template, partials and context that
   * __do not have__ a `content` property set.
   * @param {Boolean} [write] When `true`, an attempt will be made to also {@link Cachier.write} the template, partials and context that
   * __have__ a `content` property set.
   * @returns {Object} An object that contains the registration results:
   * 
   * - `data` The object that contains the template, partial fragments and/or context that have been registered
   *   - `name` The name that uniquely identifies the template, partial or context
   *   - `content` The raw content of the template, partial or context
   *   - `extension` The template file extension designation
   *   - `params` The URLSearchParams passed during the __initial__ content read
   *   - `fromRead` A flag that indicates that the data was set from a read operation
   *   - `overrideFromFileRead` A flag that indicates if the passed partial content was overridden by content from a file read
   * - `dirs` Contains the directories/sub-directories that were created
   */
  async register(data, read, write) {
    const ns = internal(this), opts = ns.this.options, log = ns.this.log, src = ns.this.options.partialsPath, dest = opts.outputPath;
    const srtn = await super.register(data, !src || !dest, write), hasSuperPrtls = srtn && srtn.data && srtn.data.length;
    let mkrtn;
    if (src && dest) {
      const prtlPrefix = opts.partialsPath || '';
      await fileStart(ns.at, opts, log, true);
      mkrtn = mkdirpMirror({ Os, Path, Fs, Fsp }, ns.at, opts, ns.this, read, src, dest, prtlPrefix, true, true, log, true);
      if (hasSuperPrtls) mkrtn = await mkrtn;
      else return mkrtn;
    } else if (log.info) {
      log.info('FS: ↩️ Partials read not performed since "partialsPath" and/or "outputPath" options have not been set'
        + ` (partialsPath = "${src}" outputPath = "${dest}")`);
    }
    if (mkrtn && hasSuperPrtls) { // template, partial or context from file reads should always override anything with the same name
      let urld;
      sloop:
      for (let dta of srtn.data) {
        for (let mdta of mkrtn.data) {
          if (mdta.name === dta.name) {
            dta.overrideFromFileRead = true;
            if (log.warn) {
              urld = Cachier.contentURL(dta.name, opts);
              log.warn(`FS: ⚠️ The "options.${urld.optionName}" URL for "${dta.name}" is overridden by the file read`);
            }
            continue sloop;
          }
        }
        mkrtn.data.push(dta);
      }
    } else return mkrtn || srtn;
  }

  /**
   * @override
   * @inheritdoc
   */
  async compile(name, template, params, extension) {
    const ns = internal(this), opts = ns.this.options, log = ns.this.log;
    await fileStart(ns.at, opts, log, true);
    return super.compile(name, template, params, extension);
  }

  /**
   * @override
   * @inheritdoc
   */
  async read(name, forContent, extension, params) {
    const ns = internal(this), opts = ns.this.options, log = ns.this.log;
    const path = await ns.this.readWriteName(name, opts, params, ns.at, forContent, extension);
    return readAndSet({ Os, Path, Fs, Fsp }, name, path, ns.this, forContent, extension, opts, ns.this, log, true);
  }

  /**
   * @override
   * @inheritdoc
   */
  async write(name, data, forContent, extension, params) {
    const ns = internal(this), opts = ns.this.options, log = ns.this.log;
    if (!opts.outputPath || !opts.cacheRawTemplates) return;
    const path = await ns.this.readWriteName(name, opts, params, ns.at, forContent, extension);
    if (log.info && forContent) {
      if (log.info) log.info(`FS: ↩️ Skipping write on template data for "${name}" (name)${extension ? ` "${extension}" (extension)` : ''}`
      + ` to file "${path}"${log.debug ? `:${Os.EOL}${data}` : ''} (compile-time)`);
      return; // template, partial or context is coming from the file system, no need to overwrite any of them
    }
    return writeAndSet({ Os, Path, Fs, Fsp }, name, path, data, ns.at, forContent, extension, opts, ns.this, log, true);
  }

  /**
   * @override
   * @inheritdoc
   */
  get metadata() {
    const ns = internal(this), md = super.metadata;
    md.originDirs = ns.at.originDirs ? [...ns.at.originDirs] : null;
    md.createdDirs = ns.at.createdDirs ? [...ns.at.createdDirs] : null;
    //md.watchedDirs = ns.at.watchedDirs ? [...ns.at.watchedDirs] : null;
    return md;
  }

  /**
   * Clears the output directories and any file watchers
   * @override
   * @param {Boolean} [all=false] `true` to clear all temporary directories created over multiple instances
   */
  async clear(all = false) {
    const ns = internal(this), opts = ns.this.options, log = ns.this.log;
    unwatchers(ns.at.watchers, ns.at.watchedDirs, log, true);
    if (all && opts.outputPathTempPrefix) {
      await cleanTempOutput({ Os, Path, Fs, Fsp }, opts.outputPathTempPrefix, log);
    } else if (opts.outputPath) {
      if (log.info) {
        log.info(`FS: ❌ Clearing "${opts.outputPath}"`);
      }
      await rmrf({ Os, Path, Fs, Fsp }, opts.outputPath);
    }
  }

  /**
   * @override
   * @inheritdoc
   */
  get readWriteNames() {
    const nmrs = super.readWriteNames;
    nmrs.namerSuper = nmrs.namer;
    nmrs.namer = readWriteName;
    return nmrs;
  }

  /**
   * @override
   * @inheritdoc
   */
  get operations() {
    const ops = super.operations;
    const op = Object.freeze({
      read: fileRenderReader,
      write: fileRenderWriter,
      scopes: Object.freeze([
        modules,
        createPath,
        rmrf,
        cleanTempOutput,
        mkdir,
        createReadAndSet,
        readAndSet,
        writeAndSet,
        extractNameParts,
        modularize,
        clearModule,
        watchPartialDir,
        unwatchers,
        mkdirpMirror,
        fileStart
      ])
    });
    if (Array.isArray(ops)) ops.splice(0, 0, op);
    else return [op, ops];
    return ops;
  }
}

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

/**
 * Initializes the file storage
 * @private
 * @ignore
 * @param {Object} store The JSON storage space
 * @param {String[]} [store.createdDirs] The list of directories that have been created (set during invocation)
 * @param {(TemplateFileOpts | Function)} optional Either the options or a `function(name:String):*` that returns an
 * option value by name
 * @param {Object} [log] The log that can contain functions for each of the following: `error`/`warn`/`info`/`debug`
 * @param {Boolean} [isCompile] `true` when compiling, _falsy_ when rendering
 * @returns {Boolean} `true` when `store.createdDirs` already contains directory paths
 */
async function fileStart(store, optional, log, isCompile) {
  const isOptionFunc = typeof optional === 'function';
  const watch = isOptionFunc ? optional('watchPaths') : optional.watchPaths;
  const unwatch = isOptionFunc ? optional('unwatchPaths') : optional.unwatchPaths;
  const opath = isOptionFunc ? optional('outputPath') : optional.outputPath;
  if (unwatch) {
    return true;
  }
  if (!Array.isArray(store.originDirs)) store.originDirs = [];
  if (watch) {
    if (!Array.isArray(store.watchedDirs)) store.watchedDirs = [];
    if (!Array.isArray(store.watchers)) store.watchers = [];
  }
  let hasDirs;
  if (!Array.isArray(store.createdDirs)) {
    const tprefix = isOptionFunc ? optional('outputPathTempPrefix') : optional.outputPathTempPrefix;
    store.createdDirs = tprefix && opath && opath.replace(/\\/g, '/').includes(tprefix.replace(/\\/g, '/')) ? [opath + '/'] : [];
  } else if (store.createdDirs.length) hasDirs = true;
  if (log && log.info) {
    log.info(`FS: 📁 Started cache with template destination set to: "${opath}"`
    + `${hasDirs ? ` with ${store.createdDirs.length} preexisting output directories` : ''} (${isCompile ? 'compile' : 'render'}-time)`);
  }
  return hasDirs;
}

/**
 * File reader that reads the contents of a file during compile-time or render-time
 * @private
 * @ignore
 * @param {String} name The name of template that will be read
 * @param {String} path The path to the template that will be read
 * @param {String} ext The path extension
 * @param {Boolean} forContent The flag indicating that the read is for content. Otherwise, the read is for rendering functions.
 * @param {(TemplateFileOpts | Function)} optional Either the options or a `function(name:String):*` that returns an
 * option value by name
 * @param {URLSearchParams} [params] The search parameters to use for the read 
 * @param {Object} store The JSON storage space
 * @param {String[]} store.createdDirs The list of directories that have been created
 * @param {Function} [readFormatter] The formatting function to use to format the read content
 * @param {Boolean} [close] When `true`, the resources will be closed after execution is complete
 * @param {Object} [log] The log that can contain functions for each of the following: `error`/`warn`/`info`/`debug`
 * @returns {(String | undefined | Boolean)} The read file template content, `undefined` when reading all partial content or `true`
 * to indicate further rendering should __not__ take place
 */
async function fileRenderReader(name, path, ext, forContent, optional, params, store, readFormatter, close, log) {
  const isOptionFunc = typeof optional === 'function';
  const mods = await modules(optional);
  const src = isOptionFunc ? optional('partialsPath') : optional.partialsPath;
  const reg = {
    registerPartial: async (path, content, extension) => {
      if (log && log.info) log.info(`FS: 🗂️ Registering template "${name}" @ "${path}" (render-time)`);
      store[path] = { path, content, extension };
    },
    unregister: path => {
      return Promise.resolve(delete store[path]);
    },
    readFormatter,
    modularize
  };
  if (!path) {
    const watch = isOptionFunc ? optional('watchPaths') : optional.watchPaths
    const unwatch = isOptionFunc ? optional('unwatchPaths') : optional.unwatchPaths;
    if (unwatch) {
      const watchCnt = (store.watchers && store.watchers.length) || 0;
      if (log && log.info) log.info(`FS: 👁️ File unwatch detected (for ${watchCnt} watchers), skipping rendering`);
      if (watchCnt) unwatchers(store.watchers, store.watchedDirs, log);
      return true;
    }
    const dest = isOptionFunc ? optional('outputPath') : optional.outputPath;
    const policy = isOptionFunc ? optional('renderTimePolicy') : optional.renderTimePolicy;
    const readAll = policy.includes('read-all-on-init-when-empty');
    const hadDirs = await fileStart(store, optional, log);
    if (hadDirs) {
      if (watch && src) {
        for (let dir of store.originDirs) {
          if (store.watchedDirs.includes(dir)) continue;
          watchPartialDir(mods, store, optional, reg, dir, src, log);
        }
      }
      return;
    }
    if (!readAll && !watch && Object.getOwnPropertyNames(store.data).length) {
      if (log && log.debug) {
        log.debug(`FS: 📁 Cache initialization from source "${src}" to "${dest}" will be skipped for policy "${optional('renderTimePolicy')}"`);
      }
      return;
    }
    if (src && dest) await mkdirpMirror(mods, store, optional, reg, readAll, src, dest, src, true, true, log);
    return;
  }
  const rtn = await createReadAndSet(mods, forContent, store, optional, reg, path, src, ext, log);
  return rtn.content;
}

/**
 * File writer that writes the contents of a file during compile-time or render-time
 * @private
 * @ignore
 * @param {String} name The name of template that will be written
 * @param {String} path The path to the template that will be written
 * @param {String} ext The path extension
 * @param {Boolean} forContent The flag indicating that the write is for content. Otherwise, the write is for rendering functions.
 * @param {(TemplateFileOpts | Function)} optional Either the options or a `function(name:String):*` that returns an
 * option value by name
 * @param {URLSearchParams} [params] The search parameters to use for the write 
 * @param {Object} store The JSON storage space
 * @param {String[]} store.createdDirs The list of directories that have been created
 * @param {String} data The code block that will be written
 * @param {Function} [writeFormatter] The formatting function to use to format the write content
 * @param {Boolean} [close] When `true`, the resources will be closed after execution is complete
 * @param {Object} [log] The log that can contain functions for each of the following: `error`/`warn`/`info`/`debug`
 * @returns {Function} The rendering function
 */
async function fileRenderWriter(name, path, ext, forContent, optional, params, store, data, writeFormatter, close, log) {
  const isOptionFunc = typeof optional === 'function';
  const src = isOptionFunc ? optional('partialsPath') : optional.partialsPath;
  const mods = await modules(optional);
  const extr = extractNameParts(mods, store, path, src);
  const registrant = { writeFormatter, modularize };
  const rtn = await writeAndSet(mods, extr.name, path, data, store, forContent, extr.extension || ext, optional, registrant, log);
  return rtn && rtn.func;
}

/**
 * Creates an object with all the modules required by {@link CachierFiles}
 * @private
 * @ignore
 * @param {(TemplateFileOpts | Function)} optional Either the options or a `function(name:String):*` that returns an
 * option value by name
 * @returns {Object} mods The modules required by {@link CachierFiles}
 * @returns {Object} mods.Os The [os](https://nodejs.org/docs/latest/api/os.html) module
 * @returns {Object} mods.Path The [path](https://nodejs.org/docs/latest/api/path.html) module
 * @returns {Object} mods.Fs The [fs](https://nodejs.org/docs/latest/api/fs.html) module
 * @returns {Object} mods.Fsp The [fs promises](https://nodejs.org/docs/latest/api/fs.htmll#fs_fs_promises_api) module
 */
async function modules(optional) {
  const cjs = typeof optional === 'function' ? optional('useCommonJs') : optional.useCommonJs;
  const mods = {
    Os: cjs ? require('os') : /*await import('os')*/null,
    Path: cjs ? require('path') : /*await import('path')*/null,
    Fs: cjs ? require('fs') : /*await import('fs')*/null
  };
  mods.Fsp = mods.Fs.promises;
  return mods;
}

/**
 * Creates any missing directories/sub-directories within an output directory from all input sub-directories
 * @private
 * @ignore
 * @param {Object} mods The modules that will be used
 * @param {Object} mods.Os The [os](https://nodejs.org/api/os.html) module
 * @param {Object} mods.Path The [path](https://nodejs.org/api/path.html) module
 * @param {Object} mods.Fs The [fs](https://nodejs.org/api/fs.html) module
 * @param {Object} mods.Fsp The [fs.promises](https://nodejs.org/api/fs.html#fs_fs_promises_api) API
 * @param {Object} store The JSON storage space
 * @param {String[]} storage.createdDirs The list of directories that have been created
 * @param {(TemplateFileOpts | Function)} optional Either the options or a `function(name:String):*` that returns an
 * option value by name
 * @param {Object} registrant The registrant {@link CachierFiles} or similar object
 * @param {Function} registrant.registerPartial The {@link CachierFiles.registerPartial} or similar function
 * @param {Function} registrant.unregister The {@link CachierFiles.unregister} or similar function
 * @param {Function} [registrant.readFormatter] The {@link CachierFiles.readFormatter} or similar function
 * @param {Function} [registrant.writeFormatter] The {@link CachierFiles.writeFormatter} or similar function
 * @param {Function} registrant.modularize The {@link CachierFiles.modularize} or similar function
 * @param {Boolean} read When `true`, an attempt to read parials will be made
 * @param {String} idir The input directory that contains the directories/sub-directories that will be built within the output directories
 * @param {String} odir Then output directory where directories/sub-directories will be created
 * @param {String} [partialPrefix] A prefix path that will be excluded from the name of any partials discovered
 * @param {Boolean} [cleanOutput=false] The flag that indicates that the specified __output__ directory (along with any __sub__ directories) will be
 * __removed__ within the output (if not present)
 * @param {Boolean} [createOutput=true] The flag that indicates that the specified __input__ directory (along with any __parent__ directories) will be
 * __created__ within the output (if not present)
 * @param {Object} [log] The log that can contain functions for each of the following: `error`/`warn`/`info`/`debug`
 * @param {Boolean} [isCompile] `true` when execution is for compilation, _falsy_ when rendering
 * @returns {Object[]} `data` The partial fragments that have been registered
 * @returns {String} `data[].name` The partial name
 * @returns {String} `data[].content` The partial template content
 * @returns {Boolean} `data[].read` The flag indicated that the partial was loaded as a result of a read operation
 * @returns {String[]} `dirs` All the directories/sub-directories that are created within the output directory
 */
async function mkdirpMirror(mods, store, optional, registrant, read, idir, odir, partialPrefix, cleanOutput = false, createOutput = true, log = null, isCompile = false) {
  if (cleanOutput) await rmrf(mods, odir);
  const isOptionFunc = typeof optional === 'function';
  const base = isOptionFunc ? optional('relativeTo') : optional.relativeTo;
  const tbase = (idir.replace(/\\/g, '/').indexOf(partialPrefix) < 0 && partialPrefix) || '';
  const idirResolved = mods.Path.resolve(base, tbase, idir);
  const sdirs = await mods.Fsp.readdir(idirResolved), prtls = [], rtn = { data: [], dirs: [] };
  if (cleanOutput || createOutput) {
    await mkdir(mods, store, odir, log, isCompile);
    rtn.dirs.push(odir);
  }
  const watch = isOptionFunc ? optional('watchPaths') : optional.watchPaths;
  if (watch) watchPartialDir(mods, store, optional, registrant, idirResolved, partialPrefix, log, isCompile);
  if (!store.originDirs.includes(idirResolved)) store.originDirs.push(idirResolved);
  let prtl;
  for (let sdir of sdirs) {
    const indirx = mods.Path.join(idir, sdir), indir = mods.Path.resolve(idirResolved, sdir);
    const stat = await mods.Fsp.stat(indir);
    if (stat.isDirectory()) {
      const outdir = mods.Path.resolve(odir, sdir);
      var hasOut = false;
      try {
        hasOut = store.createdDirs.includes(mods.Path.join(outdir, mods.Path.sep)) || (await mods.Fsp.stat(outdir)).isDirectory();
      } catch (e) {
        hasOut = false;
      }
      if (!hasOut) {
        await mkdir(mods, store, outdir, log, isCompile);
        rtn.dirs.push(outdir);
      }
      const rtnd = await mkdirpMirror(mods, store, optional, registrant, read, indirx, outdir, partialPrefix, false, false, log, isCompile);
      rtn.data = rtnd.data && rtnd.data.length ? rtn.data.concat(rtnd.data) : rtn.data;
      rtn.dirs = rtnd.dirs && rtnd.dirs.length ? rtn.dirs.concat(rtnd.dirs) : rtn.dirs;
      if (watch) watchPartialDir(mods, store, optional, registrant, indir, partialPrefix, log, isCompile);
      if (!store.originDirs.includes(indir)) store.originDirs.push(indir);
    } else if (stat.isFile()) {
      if (read) {
        prtl = createReadAndSet(mods, true, store, optional, registrant, indirx, partialPrefix, null, log, isCompile);
        prtls.push(prtl);
      } else {
        prtl = extractNameParts(mods, store, indirx, partialPrefix);
        prtl.path = indirx;
        rtn.data.push(prtl);
      }
    }
  }
  for (let prtl of prtls) {
    prtl = await prtl;
    rtn.data.push(prtl);
    await registrant.registerPartial(prtl.name, prtl.content, prtl.extension);
  }
  return rtn;
}

/**
 * Calls `Fs.mkdir` when the directory hasn't already been created
 * @private
 * @ignore
 * @param {Object} mods The modules that will be used
 * @param {Object} mods.Os The [os](https://nodejs.org/api/os.html) module
 * @param {Object} mods.Path The [path](https://nodejs.org/api/path.html) module
 * @param {Object} mods.Fs The [fs](https://nodejs.org/api/fs.html) module
 * @param {Object} mods.Fsp The [fs.promises](https://nodejs.org/api/fs.html#fs_fs_promises_api) API
 * @param {Object} store The JSON storage space
 * @param {String[]} storage.createdDirs The list of directories that have been created
 * @param {String} dir The directory to make when missing
 * @param {Object} [log] The log that can contain functions for each of the following: `error`/`warn`/`info`/`debug`
 * @param {Boolean} [isCompile] `true` when execution is for compilation, _falsy_ when rendering
 */
async function mkdir(mods, store, dir, log, isCompile) {
  const fdir = mods.Path.join(dir, mods.Path.sep); // always w/trailing separator
  if (!store.createdDirs.includes(fdir)) {
    if (log && log.info) log.info(`FS: 📁 Creating directory: ${dir} (${isCompile ? 'compile' : 'render'}-time)`);
    await mods.Fsp.mkdir(dir, { recursive: true });
    store.createdDirs.push(fdir);
  }
}

/**
 * Extracts the partial name from a given path and initiates a {@link readAndSet} on the partial
 * @private
 * @ignore
 * @param {Object} mods The modules that will be used
 * @param {Object} mods.Os The [os](https://nodejs.org/api/os.html) module
 * @param {Object} mods.Path The [path](https://nodejs.org/api/path.html) module
 * @param {Object} mods.Fs The [fs](https://nodejs.org/api/fs.html) module
 * @param {Object} mods.Fsp The [fs.promises](https://nodejs.org/api/fs.html#fs_fs_promises_api) API
 * @param {Boolean} forContent The flag indicating that the read is for content. Otherwise, the read is for rendering functions.
 * @param {Object} store The JSON storage space
 * @param {(TemplateFileOpts | Function)} optional Either the options or a `function(name:String):*` that returns an
 * option value by name
 * @param {Object} registrant The registrant {@link CachierFiles} or similar object
 * @param {Function} registrant.registerPartial The {@link CachierFiles.registerPartial} or similar function
 * @param {Function} registrant.unregister The {@link CachierFiles.unregister} or similar function
 * @param {Function} [registrant.readFormatter] The {@link CachierFiles.readFormatter} or similar function
 * @param {Function} registrant.modularize The {@link CachierFiles.modularize} or similar function
 * @param {String} path The path to the partial
 * @param {String} [partialPrefix] A prefix path that will be excluded from the name of the partial
 * @param {String} ext The path extension
 * @param {Object} [log] The log that can contain functions for each of the following: `error`/`warn`/`info`/`debug`
 * @param {Boolean} [isCompile] `true` when execution is for compilation, _falsy_ when rendering
 * @returns {Promise} The promise from {@link readAndSet}
 */
function createReadAndSet(mods, forContent, store, optional, registrant, path, partialPrefix, ext, log, isCompile) {
  const { name, extension } = extractNameParts(mods, store, path, partialPrefix);
  return readAndSet(mods, name, path, store, forContent, extension || ext, optional, registrant, log, isCompile);
}

/**
 * Reads either a template partial or compiled source. When reading a partial, the content will also be registered
 * @private
 * @ignore
 * @param {Object} mods The modules that will be used
 * @param {Object} mods.Os The [os](https://nodejs.org/api/os.html) module
 * @param {Object} mods.Path The [path](https://nodejs.org/api/path.html) module
 * @param {Object} mods.Fs The [fs](https://nodejs.org/api/fs.html) module
 * @param {Object} mods.Fsp The [fs.promises](https://nodejs.org/api/fs.html#fs_fs_promises_api) API
 * @param {String} name The name of the template partial or source to read
 * @param {String} path The full file path to the template partial or source that will be read
 * @param {Object} store The JSON storage space
 * @param {Boolean} forContent `true` for template, partials or context, `false` for sources
 * @param {String} extension The file extension to the file 
 * @param {(TemplateFileOpts | Function)} optional Either the options or a `function(name:String):*` that returns an
 * option value by name
 * @param {Object} registrant The registrant {@link CachierFiles} or similar object
 * @param {Function} registrant.registerPartial The {@link CachierFiles.registerPartial} or similar function
 * @param {Function} registrant.unregister The {@link CachierFiles.unregister} or similar function
 * @param {Function} [registrant.readFormatter] The {@link CachierFiles.readFormatter} or similar function
 * @param {Object} [log] The log that can contain functions for each of the following: `error`/`warn`/`info`/`debug`
 * @param {Boolean} [isCompile] `true` when execution is for compilation, _falsy_ when rendering
 * @returns {Object} [read] The read metadata
 * @returns {String} [read.name] The passed name
 * @returns {String} [read.path] The passed path
 * @returns {String} [read.extension] The file extension used
 * @returns {String} [read.content] The partial content when reading a partial
 * @returns {Boolean} [read.read] `true` when reading a partial
 * @returns {Function} [read.func] The compiled source rendering function when `forContent` is _falsy_
 */
async function readAndSet(mods, name, path, store, forContent, extension, optional, registrant, log, isCompile) {
  if (log && log.info) {
    log.info(`FS: 📖 Reading template ${forContent ? 'partial' : 'code'} for "${name}" (name) from "${path}" (${isCompile ? 'compile' : 'render'}-time)`);
  }
  const isOptionFunc = typeof optional === 'function';
  const encoding = isOptionFunc ? optional('encoding') : optional.encoding;
  const rtn = { name, path, extension };
  if (forContent) {
    rtn.content = (await mods.Fsp.readFile(path, !isOptionFunc ? optional : { encoding })).toString(encoding);
    rtn.read = true;
    if (typeof registrant.readFormatter === 'function' && typeof rtn.content === 'string') {
      const ropts = isOptionFunc ? optional('readFormatOptions') : optional.readFormatOptions;
      rtn.content = registrant.readFormatter(rtn.content, ropts);
    }
    await registrant.registerPartial(rtn.name, rtn.content, rtn.extension);
  } else {
    rtn.func = await modularize(mods, path, true, optional, store);
  }
  return rtn;
}

/**
 * Reads either a template partial or compiled source. When reading a partial, the content will also be registered
 * @private
 * @ignore
 * @param {Object} mods The modules that will be used
 * @param {Object} mods.Os The [os](https://nodejs.org/api/os.html) module
 * @param {Object} mods.Path The [path](https://nodejs.org/api/path.html) module
 * @param {Object} mods.Fs The [fs](https://nodejs.org/api/fs.html) module
 * @param {Object} mods.Fsp The [fs.promises](https://nodejs.org/api/fs.html#fs_fs_promises_api) API
 * @param {String} name The name of the template partial or source to read
 * @param {String} path The full file path to the template partial or source that will be read
 * @param {(String | Function)} data Either the template content (string) or template code (string or function) that will be written
 * @param {Object} store The JSON storage space
 * @param {Boolean} forContent `true` for template, partials or context, `false` for sources
 * @param {String} extension The file extension to the file 
 * @param {(TemplateFileOpts | Function)} optional Either the options or a `function(name:String):*` that returns an
 * option value by name
 * @param {Object} registrant The registrant {@link CachierFiles} or similar object
 * @param {Function} [registrant.writeFormatter] The {@link CachierFiles.writeFormatter} or similar function
 * @param {Object} [log] The log that can contain functions for each of the following: `error`/`warn`/`info`/`debug`
 * @param {Boolean} [isCompile] `true` when execution is for compilation, _falsy_ when rendering
 * @returns {Object} [write] The read metadata
 * @returns {String} [write.name] The passed name
 * @returns {String} [write.path] The passed path
 * @returns {String} [write.extension] The file extension used
 * @returns {String} [write.content] The partial content when writting a partial
 * @returns {Function} [write.func] The compiled source rendering function when `forContent` is _falsy_
 */
async function writeAndSet(mods, name, path, data, store, forContent, extension, optional, registrant, log, isCompile) {
  const isOptionFunc = typeof optional === 'function', logLabel = log ? ` (${isCompile ? 'compile' : 'render'}-time)` : undefined;
  const useCache = isOptionFunc ? optional('cacheRawTemplates') : optional.cacheRawTemplates;
  if (!useCache) {
    if (log && log.info) {
      log.info(`FS: ↩️ Skipping write due to "options.cacheRawTemplates" is turned off for template "${name}" @ "${path}"${logLabel}`);
    }
    return;
  }
  let writePath = mods.Path.isAbsolute(path) ? path : null;
  if (!writePath) {
    if (/^https?:\/?\/?/i.test(path)) return;
    const opath = isOptionFunc ? optional('outputPath') : optional.outputPath;
    writePath = mods.Path.join(opath, path);
  }
  if (log && log.info) {
    log.info(`FS: ✏️ Writting template ${forContent ? 'partial' : 'code'} for "${name}" (name) to file "${writePath}"${logLabel}`);
  }
  const encoding = isOptionFunc ? optional('encoding') : optional.encoding;
  data = !forContent && typeof data === 'function' ? data.toString() : data;
  const dataType = typeof data;
  if (typeof registrant.writeFormatter === 'function' && dataType === 'string') {
    const wopts = isOptionFunc ? optional('writeFormatOptions') : optional.writeFormatOptions;
    data = registrant.writeFormatter(data, wopts);
  }
  if (dataType !== 'string') {
    if (log.info) {
      log.info(`FS: ↩️ Skipping write for unsupported type=${dataType} on template code for "${name}" (name)`
      + ` to file "${writePath}" (forContent? ${forContent})${logLabel}${log.debug ? `: ${mods.Os.EOL}${data}` : ''}`);
    }
    return;
  }
  if (!forContent) {
    const cjs = isOptionFunc ? optional('useCommonJs') : optional.useCommonJs;
    data = `${cjs ? 'module.exports=' : 'export '}${data}`;
  }
  await createPath(mods, store, writePath, log, isCompile);
  await mods.Fsp.writeFile(writePath, data, !isOptionFunc ? optional : { encoding });
  const rtn = { name, path, extension };
  if (forContent) {
    rtn.content = data;
  } else {
    rtn.func = await modularize(writePath, true, optional, store);
  }
  return rtn;
}

/**
 * Extracts a partial name from a partial path
 * @private
 * @ignore
 * @param {Object} mods The modules that will be used
 * @param {Object} mods.Os The [os](https://nodejs.org/api/os.html) module
 * @param {Object} mods.Path The [path](https://nodejs.org/api/path.html) module
 * @param {Object} mods.Fs The [fs](https://nodejs.org/api/fs.html) module
 * @param {Object} mods.Fsp The [fs.promises](https://nodejs.org/api/fs.html#fs_fs_promises_api) API
 * @param {Object} store The JSON storage space
 * @param {String} path The path to the partial
 * @param {String} [partialPrefix] A prefix path that will be excluded from the name of the partial
 * @returns {Object} The parts `{ name:String, extension:String }`
 */
function extractNameParts(mods, store, path, partialPrefix) {
  let name = mods.Path.parse(path), ppx = partialPrefix ? partialPrefix.replace(/\\/g, '/') : '';
  let extension = name.ext;
  name = (extension ? path.replace(extension, '') : path).replace(/\\/g, '/');
  name = ppx ? name.substring(name.indexOf(ppx) + ppx.length).replace(/^\\|\//, '') : name;
  return { name, extension };
}

/**
 * Watches a partial directory for file adds, deletions, etc. and registers or unregisters the
 * partial accordingly
 * @private
 * @ignore
 * @param {Object} mods The modules that will be used
 * @param {Object} mods.Os The [os](https://nodejs.org/api/os.html) module
 * @param {Object} mods.Path The [path](https://nodejs.org/api/path.html) module
 * @param {Object} mods.Fs The [fs](https://nodejs.org/api/fs.html) module
 * @param {Object} mods.Fsp The [fs.promises](https://nodejs.org/api/fs.html#fs_fs_promises_api) API
 * @param {Object} store The JSON storage space
 * @param {Object} registrant The registrant {@link CachierFiles} or similar object
 * @param {Function} registrant.registerPartial The {@link CachierFiles.registerPartial} or similar function
 * @param {Function} registrant.unregister The {@link CachierFiles.unregister} or similar function
 * @param {Function} [registrant.readFormatter] The {@link CachierFiles.readFormatter} or similar function
 * @param {Function} registrant.modularize The {@link CachierFiles.modularize} or similar function
 * @param {String} dir The directory that will be watched
 * @param {String} [partialPrefix] A prefix path that will be excluded from the name of the partial
 * @param {Object} [log] The log that can contain functions for each of the following: `error`/`warn`/`info`/`debug`
 * @param {Boolean} [isCompile] `true` when execution is for compilation, _falsy_ when rendering
 * @returns {Function} The listener function used in the watch
 */
function watchPartialDir(mods, store, optional, registrant, dir, partialPrefix, log, isCompile) {
  const logLabel = log && `(${isCompile ? 'compile' : 'render'}-time)`;
  if (store.watchedDirs.includes(dir)) {
    if (log && log.info) {
      log.info(`FS: 👁️ Watch directory ${dir} already being watched (watching ${store.watchedDirs.length} dirs) ${logLabel}`);
    }
    return;
  }
  const files = {};
  let watcher;
  const filesCachierWatchListener = async (type, filename) => {
    if (!filename) return;
    // throttle multiple changes
    if (files[filename] && files[filename][type] && files[filename][type].wait) return;
    files[filename] = files[filename] || {};
    files[filename][type] = files[filename][type] || {};
    files[filename][type].wait = setTimeout(() => files[filename][type].wait = false, 100);
    var stat, path = mods.Path.join(dir, filename);
    if (!store.watchedDirs || !store.watchedDirs.includes(dir)) {
      if (log && log.info) {
        log.info(`FS: 👁️ Stopping watch directory ${dir} from: "${type}" on ${path} ${logLabel}`);
      }
      watcher.close();
    }
    try {
      stat = await mods.Fsp.stat(path);
      if (!stat.isFile()) stat = null;
    } catch (err) {
      stat = null;
    }
    if (!stat && type === 'rename') { // file has been removed
      const { name, extension } = extractNameParts(mods, store, path, partialPrefix);
      if (log && log.info) {
        const extTxt = extension ? ` (extension: "${extension}")` : '';
        log.info(`FS: 👁️ Watch detected "${type}" unregistering template partial "${name}"${extTxt} from: ${path} ${logLabel}`);
      }
      await registrant.unregister(name);
    } else if (stat) {
      files[filename][type].wait = true;
      if (log && log.info) log.info(`FS: 👁️ Watch detected "${type}" registering template partial from: ${path} ${logLabel}`);
      const prtl = await createReadAndSet(mods, true, store, optional, registrant, path, partialPrefix, null, log, isCompile);
      if (log && log.debug) log.debug(`FS: 👁️ Watch detected "${type}" registering template partial "${prtl.name}" from: ${path} ${logLabel}`);
      files[filename][type].wait = false;
    }
  };
  watcher = mods.Fs.watch(dir, { persistent: false }, filesCachierWatchListener);
  store.watchedDirs.push(dir);
  store.watchers.push(Object.freeze({ dir, isCompile, watcher }));
  if (log && log.info) log.info(`FS: 👁️ Watching template partial directory "${dir}" (watching ${store.watchedDirs.length} dirs) ${logLabel}`);
  return filesCachierWatchListener;
}

/**
 * Unwatches any watchers that may have been set
 * @private
 * @ignore
 * @param {Object[]} [watchers] The watchers set during {@link watchPartialDir}
 * @param {String[]} [watchedDirs] The directories created during {@link watchPartialDir}
 * @param {Object} [log] The log that can contain functions for each of the following: `error`/`warn`/`info`/`debug`
 * @param {Boolean} [isCompile] `true` when execution is for compilation, _falsy_ when rendering
 * @returns {Integer} The number of closed file watchers
 */
function unwatchers(watchers, watchedDirs, log, isCompile) {
  let cnt = 0;
  if (watchers) {
    for (let wtch of watchers) {
      wtch.watcher.close();
      cnt++;
    }
    watchers.length = 0;
  }
  if (watchedDirs) watchedDirs.length = 0;
  if (log && log.info) log.info(`FS: 👁️ Closed ${cnt} file watchers (${isCompile ? 'compile' : 'render'}-time)`);
  return cnt;
}

/**
 * Cleans any temporary directories that were previously generated by {@link CachierFiles}
 * @private
 * @ignore
 * @param {Object} mods The modules that will be used
 * @param {Object} mods.Os The [os](https://nodejs.org/api/os.html) module
 * @param {Object} mods.Path The [path](https://nodejs.org/api/path.html) module
 * @param {Object} mods.Fs The [fs](https://nodejs.org/api/fs.html) module
 * @param {Object} mods.Fsp The [fs.promises](https://nodejs.org/api/fs.html#fs_fs_promises_api) API
 * @param {String} prefix The directory prefix used to designate that the temprorary directory was generated by {@link CachierFiles}
 * @param {Object} [log] The log that can contain functions for each of the following: `error`/`warn`/`info`/`debug`
 */
async function cleanTempOutput(mods, prefix, log) {
  if (!prefix) return;
  if (log && log.info) log.info(`FS: ❌ Clearing ALL temporary dirs that are prefixed with "${prefix}"`);
  const tmp = mods.Os.tmpdir(), tdirs = await mods.Fsp.readdir(tmp), rmProms = [];
  for (let tdir of tdirs) {
    if (tdir.includes(prefix)) {
      if (log && log.info) log.info(`FS: ❌ Removing "${mods.Path.join(tmp, tdir)}"`);
      rmProms.push(rmrf(mods, mods.Path.join(tmp, tdir)));
    }
  }
  return Promise.all(rmProms);
}

/**
 * Removes a directory and any sub directories/files (i.e. `rmdir -rf`)
 * @private
 * @ignore
 * @param {Object} mods The modules that will be used
 * @param {Object} mods.Os The [os](https://nodejs.org/api/os.html) module
 * @param {Object} mods.Path The [path](https://nodejs.org/api/path.html) module
 * @param {Object} mods.Fs The [fs](https://nodejs.org/api/fs.html) module
 * @param {Object} mods.Fsp The [fs.promises](https://nodejs.org/api/fs.html#fs_fs_promises_api) API
 * @param {String} path the directory that will be removed along with any sub-directories/files
 */
async function rmrf(mods, path) {
  var stats, subx;
  try {
    stats = await mods.Fsp.stat(path);
  } catch (e) {
    stats = null;
  }
  if (stats && stats.isDirectory()) {
    for (let sub of await mods.Fsp.readdir(path)) {
      subx = mods.Path.resolve(path, sub);
      stats = await mods.Fsp.stat(subx);
      if (stats.isDirectory()) await rmrf(mods, subx);
      else if (stats.isFile() || stats.isSymbolicLink()) await mods.Fsp.unlink(subx);
    }
    await mods.Fsp.rmdir(path); // dir path should be empty
  } else if (stats && (stats.isFile() || stats.isSymbolicLink())) await mods.Fsp.unlink(path);
}

/**
 * Generates the full name path for a given template name
 * @private
 * @ignore
 * @param {Object} nmrs The naming functions container defined by {@link CachierFiles.readWriteNames}
 * @param {Function} nmrs.namerSuper The naming function defined by the `default` naming function from
 * {@link Cachier.readWriteNames}
 * @param {String} partialName The name of the partial that will be converted into a name suitable for a read operation
 * @param {(TemplateFileOpts | Function(name:String):*)} optional Either the {@link TemplateFileOpts} or a function that takes a
 * single name argument and returns the option value
 * @param {URLSearchParams} [params] The parameters that should be used in the converted name
 * @param {Object} store The storage object that can contain metadata used by naming operations
 * @param {Boolean} forContent The flag indicating if the converted name is being used to capture template, partials or context
 * @param {String} extension The file extension override for the converted name (omit to use the default extension set in the options)
 * @param {Boolean} forContext The flag indicating if the converted name is being used to capture context
 * @returns {String} An name suitable for read/write operations
 */
async function readWriteName(nmrs, name, optional, params, store, forContent, extension, forContext) {
  const fullName = (await nmrs.namerSuper.apply(this, arguments)) || name;
  const isOptionFunc = typeof optional === 'function';
  forContext = forContext || (name === (isOptionFunc ? optional('defaultContextName') : optional.defaultContextName));
  const forTempl = !forContext && (name === (isOptionFunc ? optional('defaultTemplateName') : optional.defaultTemplateName));
  const cjs = isOptionFunc ? optional('useCommonJs') : optional.useCommonJs;
  const Path = cjs ? require('path') : null;/*await import('path')*/
  if (Path.isAbsolute(fullName)) return fullName;

  const isAbs = /^https?:\/?\/?/i.test(fullName), url = new URL(isAbs ? fullName : `http://example.com/${fullName}`);
  const path = url.pathname; // ignore params?

  const bypass = forContext || forContent ? isOptionFunc ? optional('bypassUrlRegExp') : optional.bypassUrlRegExp : null;
  const isBypass = bypass && fullName.match(bypass);
  const base = (!isBypass && (isOptionFunc ? optional('relativeTo') : optional.relativeTo)) || '';
  const pathOpt = forContext ? 'contextPath' : forTempl ? 'templatePath' : 'partialsPath';
  let contentPath = isOptionFunc ? optional(pathOpt) : optional[pathOpt];
  contentPath = forContent && contentPath && path.replace(/\\/g, '/').indexOf(contentPath) < 0 ? contentPath : '';
  let dir = '';
  if (!isBypass && !forContext && !contentPath) {
    dir = (isOptionFunc ? optional('outputPath') : optional.outputPath) || '';
    dir = dir ? `${dir}${dir.endsWith('/') ? '' : '/'}` : '';
  }
  
  const pths = [];
  if (base) pths.push(base);
  if (dir) pths.push(dir);
  if (contentPath) pths.push(contentPath);
  pths.push(path);
  return Path.join(...pths).replace(/\\+|(?:\/\/+)/g, '/');
}

/**
 * Dynamically loads/returns a module from an
 * [`import`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/import#Dynamic_Imports)
 * or [`require`](https://nodejs.org/api/modules.html#modules_require_id)
 * @private
 * @ignore
 * @param {String} path The module path to load (with or w/o the file extension)
 * @param {Boolean} [clear] `true` to clear the module from cache (when possible)
 * @param {(TemplateFileOpts | Function(name:String):*)} optional Either the {@link TemplateFileOpts} or a function that takes a
 * single name argument and returns the option value
 * @param {Object} store The storage object that can contain metadata used by naming operations
 * @returns {Function} The modularized function
 */
async function modularize(path, clear, optional, store) {
  if (clear) clearModule(store, optional, path);
  const isOptionFunc = typeof optional === 'function';
  const cjs = isOptionFunc ? optional('useCommonJs') : optional.useCommonJs;
  return cjs ? require(path) : /* TODO : ESM use... await import(path)*/null;
}

/**
 * Clears a module from cache
 * @private
 * @ignore
 * @param {Object} store The JSON storage space
 * @param {(TemplateFileOpts | Function(name:String):*)} optional Either the {@link TemplateFileOpts} or a function that takes a
 * single name argument and returns the option value
 * @param {String} path The path to a module that will be removed
 */
function clearModule(store, optional, path) {
  if (store.sources && store.sources[path]) {
    delete store.sources[path];
  }
  const isOptionFunc = typeof optional === 'function';
  const cjs = isOptionFunc ? optional('useCommonJs') : optional.useCommonJs;
  if (!cjs) return;
  const rpth = require.resolve(path);
  if (require.cache[rpth] && require.cache[rpth].parent) {
    let i = require.cache[rpth].parent.children.length;
    while (i--) {
      if (require.cache[rpth].parent.children[i].id === rpth) {
        require.cache[rpth].parent.children.splice(i, 1);
      }
    }
  }
  delete require.cache[rpth];
}

/**
 * Generates a path from an partial template name
 * @private
 * @ignore
 * @param {Object} mods The modules that will be used
 * @param {Object} mods.Os The [os](https://nodejs.org/api/os.html) module
 * @param {Object} mods.Path The [path](https://nodejs.org/api/path.html) module
 * @param {Object} mods.Fs The [fs](https://nodejs.org/api/fs.html) module
 * @param {Object} mods.Fsp The [fs.promises](https://nodejs.org/api/fs.html#fs_fs_promises_api) API
 * @param {Object} store The JSON storage space
 * @param {String[]} store.createdDirs The list of directories that have been created
 * @param {String} path The path to create
 * @param {Object} [log] The log that can contain functions for each of the following: `error`/`warn`/`info`/`debug`
 * @param {Boolean} [isCompile] `true` when execution is for compilation, _falsy_ when rendering
 */
async function createPath(mods, store, path, log, isCompile) {
  const fprts = mods.Path.parse(path);
  if (fprts.ext) fprts.base = fprts.ext = fprts.name = null;
  const fdir = mods.Path.format(fprts);
  if (!store.createdDirs.includes(fdir)) {
    if (log && log.info) log.info(`FS: 📁 Creating directory path (when nonexistent): ${fdir} (${isCompile ? 'compile' : 'render'}-time)`);
    await mods.Fsp.mkdir(fdir, { recursive: true });
    store.createdDirs.push(mods.Path.join(fdir, mods.Path.sep)); // always w/trailing separator
  }
}

// private mapping substitute until the following is adopted: https://github.com/tc39/proposal-class-fields#private-fields
let map = new WeakMap();
let internal = function(object, parent) {
  if (!map.has(object)) map.set(object, {});
  return {
    at: map.get(object),
    this: object
  };
};

1.0.0 (2019-12-16)

Full Changelog