Source: build/build.js

/**
 * MIT license
 * Copyright © 2025 Steve Butler (henspace.com)
 * Permission is hereby granted, free of charge, to any person obtaining a
 * copy of this software and associated documentation files (the “Software”),
 * to deal in the Software without restriction, including without limitation
 * the rights to use, copy, modify, merge, publish, distribute, sublicense,
 * and/or sell copies of the Software, and to permit persons to whom the
 * Software is furnished to do so, subject to the following conditions:
 *
 * The above copyright notice and this permission notice shall be included in
 * all copies or substantial portions of the Software.
 * 
 * THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
 * FROM,OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
 * IN THE SOFTWARE.
 */

/**
 * @module hcjeTools/build/build
 * @description
 * Script for building the source and intended to be run under Node.js.
 * The size of the resulting script is reduced by removing comments and leading and
 * trailing spaces.
 *
 * Usage:
 * + build configFile [--nozip]
 *     + configFile: the configuration file; see below.
 *     + --nozip: if present, no zip file is created.
 *
 * ## Configuration file
 * The script should be passed the path to a configuration file as the only command line argument.
 * This is used to control the output. The configuration file is a JSON representation of a
 * [ConfigurationOptions]{@link module:hcjeTools/build/build~ConfigurationOptions} object.
 *
 * ## Template strings
 *
 * When html, js, or md files are processed, the following predefined template strings are replaced. They are all
 * case-sensitive.
 *
 * +  **%%\_AUTHOR\_%%** replaced by **author** field  from package.json
 * +  **%%\_BUILD\_DATE\_ISO\_%%** replaced by date of the build in ISO format.
 * +  **%%\_BUILD\_YEAR\_%%** replaced by year of the build.
 * +  **%%\_BUILD\_ID\_%%** replaced by a short code based on the date and time of the build.
 * +  **%%\_DESCRIPTION\_%%** replaced by the **description** property from package.json.
 * +  **%%\_DISPLAY\_NAME\_%%** replaced by the **_customHcje.displayName** property from package.json
 * +  **%%\_LICENCE\_%%** or %%\_LICENSE\_%% replaced by the **license** property from package.json.
 * +  **%%\_NAME\_%%** replaced by the **name** property from package.json
 * +  **%%\_VERSION\_%%** replaced by the **version** property from package.json.
 * 
 * Custom template variables can be created by adding custom template values to the package's 
 * `_customHcje.templateVariables` property. This property should be an array of replacement objects, each with a 
 * name and value. The name is converted to a template name by adding `%%_` at the front and `_%%` at the end. For
 * example:
 *
 * ```
 * "customHcje" {
 *  "templateVariables": [
 *    {"name": "MY_NAME", "value": "John Doe"}
 *  ]
 * }
 * ```
 * 
 * The entry above would result in `%%_MY_NAME_%%` being replaced with `John Doe`.
 **/

/**
 * @typedef {Object} ReplacementDefn
 * @property {RegExp|string} pattern - regular expression or string to match.
 * @property {string} replacement - replacement text which can include capture groups. 
 * {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/replace}
 */

/** 
 * Configuration object.
 * @typedef {Object} ConfigurationOptions
 * @property {string} outputDir - Path to where the resulting files will be copied. Only ./build or ./docs are permitted.
 * @property {string} subDir - If provided, the build will be placed in **outputDir/subDir**.
 * @property {string} zippedOutputDir - Path to where a zipped copy of the build output will be placed.
 *   This will include the version from package.json in the file name. Note, if a subdir is provided, this will
 *   contain the contents of **outputDir/subDir** not **outputDir**. This directory is relative to the package.
 * @property {string} readme - Path to a readme file that is written to the **outputDir**. This is written pre-build,
 *   so if the source folder (root) also contains a readme file, and the **subDir** option is not set, the readme
 *   will be overwritten. It will be renamed to README.md when copied.
 * @property {string} root - Path to the source folder containing the files to copy to the **outputDir**.
 *
 * @property {Object} filter - Detail of filters to restrict the files copied to the output.
 * @property {Object} filter.includeFiles - Regular expression detail. Only file names matching the regular expression 
 *   are included. The regular expression is created as `new RegExp(regex, flags)`.
 * @property {string} filter.includeFiles.regex - The string that forms the regular expression.
 * @property {string} filter.includeFiles.flags - Any flags to use with the regular expression.
 * 
 * @property {Object} filter.excludeFiles - Regular expression detail. Any file names matching the regular expression are excluded.
 * @property {string} filter.excludeFiles.regex - The string that forms the regular expression.
 * @property {string} filter.excludeFiles.flags - Any flags to use with the regular expression.
 *  
 * @property {Object} filter.excludeDirs - Regular expression detail. Any directory names matching the regular expression are excluded.
 * @property {string} filter.excludeDirs.regex - The string that forms the regular expression.
 * @property {string} filter.excludeDirs.flags - Any flags to use with the regular expression.
 *
 * @property {Object} parserConfig - Additional detail added to the output for specific file types:
 * @property {Object} parserConfig.html - Additional text for html files:
 * @property {string} parserConfig.html.prefix - Text added at the beginning of the output.
 * @property {module:hcjeTools/build/build/ReplacementDefn[]} parseConfig.html.replacements - text replacements.
 *
 * @property {Object} parserConfig.js - Additional text for JavaScript files:
 * @property {string} parserConfig.js.prefix - Text added at the beginning of the output.
 * @property {module:hcjeTools/build/build/ReplacementDefn[]} parseConfig.js.replacements - text replacements.
 *
 * @property {Object} parserConfig.md - Additional text for Markdown files:
 * @property {string} parserConfig.md.prefix - Text added at the beginning of the output.
 * @property {module:hcjeTools/build/build/ReplacementDefn[]} parseConfig.md.replacements - text replacements.
 *
 * @property {module:hcjeTools/build/build/ZipOptions} zipOptions - Zip options for different platforms. 
 */

/**
 * @typedef {Object} ZipDetail
 * @property {string} cmd - The command to create the zip file. 
 * The text values **${zipOutputFile}** and  **${zipSourceFiles}** are respectively replaced by the path to
 * the resulting zip file and the path to the files that should be zipped.
 * @property {boolean} cd - Should the current working directory be changed to the location of the source files to be
 *   zipped when running the zip command.
 */

/**
 * @typedef{Object<string, module:hcjeTools/build/build/ZipDetail} ZipOptions - Zip options where the keys should 
 *  match the [process.platform]{@link https://nodejs.org/api/process.html#processplatform} variable.
 */

import * as fsPromises from 'node:fs/promises';
import { existsSync, mkdirSync } from 'node:fs';
import * as path from 'node:path';
import { exec } from 'node:child_process';
import * as process from 'node:process';

/**
 * Show usage.
 * @param {string} message
 * @private
 */
function showUsageAndExit(message) {
  console.error(message);
  console.error('\nUsage: build configFile [--nozip]');
  process.exit(1);
}

/** 
 * Replace template variables with information from package details.
 * Template variables begin are enclosed between %%_ and _%% characters.
 * @param {string} data - the string to process
 * @param {Object} packageDetails - details from package.json
 * @returns {string} data with template variables replaced.
 * @private
 */
function replaceTemplateVariables(data, packageDetails) {
  const date = new Date();
  data = data.replace(/%%_AUTHOR_%%/g, packageDetails.author);
  data = data.replace(/%%_BUILD_DATE_ISO_%%/g, date.toISOString().substring(0, 10));
  data = data.replace(/%%_BUILD_YEAR_%%/g, date.getFullYear());
  data = data.replace(/%%_BUILD_ID_%%/g, date.valueOf().toString(36));
  data = data.replace(/%%_DESCRIPTION_%%/g, packageDetails.description);
  data = data.replace(/%%_LICEN[CS]E_%%/g, packageDetails.license);
  data = data.replace(/%%_NAME_%%/g, packageDetails.name);
  data = data.replace(/%%_VERSION_%%/g, packageDetails.version);
  
  data = replaceCustomTemplateVariables(data, packageDetails);
  return data;
}


/**
 * Replace custom template variables. It runs through custom template values in the package's 
 * `_customHcje.templateVariables` property. This property should be an array of replacement objects, each with a 
 * name and value. The name is converted to a template name by adding `%%_` at the front and `_%%` at the end.
 * @param {string} data - the string to process.
 * @param {Object} packageDetails - the object form of package.json.
 * @returns {string}
 * @private
 */
function replaceCustomTemplateVariables(data, packageDetails) {
  const replacements = packageDetails._customHcje?.templateVariables;
  if (!replacements) {
    return data;
  }
  for (const replacement of replacements) {
    data = data.replace(`%%_${replacement.name}_%%`, replacement.value);
  }
  return data;
}

/**
 * Implement all replacements defined in array of replacement definitions.
 * @param {string} data - the string to process.
 * @param {module:hcjeTools/build/build/ReplacementDefn[]} replacements - text replacements.
 * @returns {string}
 * @private
 */
function implementReplacements(data, replacements) {
  if (!replacements) {
    return data;
  }
  for (const replacementDefn of replacements) {
    if (replacementDefn.pattern) {
      data = data.replace(replacementDefn.pattern, replacementDefn.replacement);
    } else {
      console.error(`Replacement definition found with no pattern property.`);
    }
  }
  return data;
}

/**
 * Reduce size of js file.
 * @param {string} data - data to parse 
 * @param {Object} config - configuration options
 * @param {string} config.prefix - added to start of content
 * @param {module:hcjeTools/build/build/ReplacementDefn[]} config.replacements - text replacements.
 * @param {Object} packageDetails - details from package.json
 * @param {module:hcjeTools/build/build/ReplacementDefn[]} replacements - text replacements.
 * @returns {string}
 * @private
 */ 
function parseJs(data, config, packageDetails) {
    data = data.replace(/(?:^|[\r\n\t]) *\/\*.*?\*\//gs, '');
    data = data.replace(/^\s+/gm, '');
    data = data.replace(/\s+$/gm, '');
    data = config.prefix + data;
    data = implementReplacements(data, config.replacements); 
    return replaceTemplateVariables(data, packageDetails);
}

/**
 * Parse markdown file replacing template variables.
 * @param {string} data - data to parse 
 * @param {Object} config - configuration options
 * @param {string} config.prefix - added to start of content
 * @param {module:hcjeTools/build/build/ReplacementDefn[]} config.replacements - text replacements.
 * @param {Object} packageDetails - details from package.json
 * @param {module:hcjeTools/build/build/ReplacementDefn[]} replacements - text replacements.
 * @returns {string}
 * @private
 */ 
function parseMarkdown(data, config, packageDetails) {
    data = config.prefix + data;
    data = implementReplacements(data, config.replacements); 
    return replaceTemplateVariables(data, packageDetails);
}

/**
 * Parse html file replacing template variables.
 * @param {string} data - data to parse 
 * @param {Object} config - configuration options
 * @param {string} config.prefix - added to start of content
 * @param {module:hcjeTools/build/build/ReplacementDefn[]} config.replacements - text replacements.
 * @param {Object} packageDetails - details from package.json
 * @returns {string}
 * @private
 */ 
function parseHtml(data, config, packageDetails) {
    data = config.prefix + data;
    data = implementReplacements(data, config.replacements); 
    return replaceTemplateVariables(data, packageDetails);
}


/**
 * Copy file through a parser.
 * @param {string} filePath - file to copy
 * @param {string} destFile - destination file
 * @param {Object} options
 * @param {function} options.parser - function that will parse the file
 * @param {Object} options.config - configuration options for parser
 * @param {Object} options.packageDetails - details from package.json
 * @returns {Promise}
 * @private
 */
function copyAndParse(filePath, destFile, options) {
  return fsPromises.readFile(filePath, {encoding: 'utf-8'})
    .then((contents) => {
      contents = options.parser(contents, options.config,
        options.packageDetails) ;
      return contents;  
    })
   .then((data) => {
      return fsPromises.writeFile(destFile, data, {
          encoding: 'utf-8', 
          flush: true,
      });
   })

}
/**
 * Copy file to output directory.
 * @param {string} filePath - path to file
 * @param {string} targetDir - target directory
 * @param {Object} options
 * @param {Object} options.parserConfig - options for parsers
 * @param {Object} options.packageDetails - node package information
 * @param {string} options.targetFilename - defaults to original name
 * @returns {Promise}
 * @private
 */
function copyFile(filePath, targetDir, options) {
  console.log(`Copy file ${filePath} to ${targetDir}`);
  const targetFilename = options?.targetFilename || path.basename(filePath);
  const destination = path.join(targetDir, targetFilename);
  const extension = path.extname(filePath).toLowerCase();
  let parserOptions;
  switch (extension) {
    case '.html': parserOptions = {
        parser: parseHtml,
        config: options.parserConfig.html,
        packageDetails: options.packageDetails
      };
      break;
    case '.js': parserOptions = {
        parser: parseJs,
        config: options.parserConfig.js,
        packageDetails: options.packageDetails
      };
      break;
    case '.md': parserOptions = {
        parser: parseMarkdown,
        config: options.parserConfig.md,
        packageDetails: options.packageDetails
      };
      break;

  }
  if (parserOptions) {
    return copyAndParse(filePath, destination, parserOptions);
  } else {
    return fsPromises.copyFile(filePath, destination);
  }
}

/**
 * Remove directory. It's contents are removed but the directory will remain.
 * @param {*} path - path to directory to remove.
 * @returns Promise which fulfils to undefined on success.
 * @private
 */
function removeDir(path) {
  return fsPromises.rm(path, { force: true, recursive: true });
}

/**
 * Copy directory to output directory. The name of the source 
 * directory is added to the target so that the original structure is 
 * maintained. If the directory does not exist, it's created.
 * @param {string} sourceDir - source directory
 * @param {string} targetDir - target directory
 * @param {Object} options
 * @param {RegExp} options.includeFiles - Filter for file names. Only these are included.
 * @param {RegExp} options.excludeFiles - Filter for file names. These are excluded.
 * @param {RegExp} options.excludeDirs - Filter for directories that are excluded.
 * @param {Object} options.parserConfig - Options for parsers.
 * @param {Object} options.packageDetails - Node package information
 * @returns {Promise}
 * @private
 */
async function copyDirectory(sourceDir, targetDir, options) {
  console.log(`Copy directory ${sourceDir} to ${targetDir}`);
  const exists = await existsSync(targetDir);
  if (!exists) {
    console.log(`Create ${targetDir}`);
    await mkdirSync(targetDir, {recursive: true});
  }
  return fsPromises.readdir(sourceDir, {
      encoding: 'utf-8',
      withFileTypes: true,
      recursive: false
    })
    .then((result) => {
      const promises = [];
      for (const dirent of result) {
        const direntPath = path.join(sourceDir, dirent.name);
        if (dirent.isFile()) {
          if (options.includeFiles?.test(dirent.name) && !options.excludeFiles?.test(dirent.name)) {
            promises.push(copyFile(direntPath, targetDir, options));
          } else {
            console.log(`Ignore ${dirent.name}`);
          }
        } else if (dirent.isDirectory()) {
          if (options.excludeDirs?.test(dirent.name)) {
            console.log(`Ignore directory ${dirent.name}`);
          } else {
            const destinationDir = path.join(targetDir, dirent.name);
            promises.push(copyDirectory(direntPath, destinationDir, options));
          }
        }
      }
      return Promise.all(promises);
    }) 
}


/**
 * Copy directories to output directories. The name of the source 
 * directory is added to the target so that the original structure is 
 * maintained. If the directory does not exist, it's created.
 * @param {string[]} sourceDirs - source directories
 * @param {string[]} targetDirs - target directories (length must match sourceDirs)
 * @param {Object} options
 * @param {RegExp} options.includeFiles - Filter for file names. Only these are included.
 * @param {RegExp} options.excludeFiles - Filter for file names. These are excluded.
 * @param {RegExp} options.excludeDirs - Filter for directories that are excluded.
 * @param {Object} options.parserConfig - Options for parsers.
 * @param {Object} options.packageDetails - Node package information
 * @returns {Promise}
 * @private
 */
async function copyDirectories(sourceDirs, targetDirs, options) {
  if (sourceDirs.length != targetDirs.length) {
    throw new Error("Cannot copy directories as source and target directories lengths are different.");
  }
  for (let index = 0; index < sourceDirs.length; index++) {
    await copyDirectory(sourceDirs[index], targetDirs[index], options);
  }
  return Promise.resolve();
}

/**
 * Compress folder. 
 * The zip command is executed. The ${zipSourceDir} and ${zipOutputDir} parameters are replaced by the sourceDir and
 * outputDir parameters.
 * @param {module:hcjeTools/build/build/ZipOptions} zipOptions - options to create the zip file
 * @param {string} sourceFiles - path to the files to zip. This is relative to the package script.
 * @param {string} outputFile - path to the resulting zip file. This is relative to the package script. 
 * @returns {Promise}
 * @private
 */
function compressFolder(zipOptions, sourceFiles, outputFile) {
  const zipPlatform = zipOptions[process.platform];
  if (!zipPlatform) {
    console.error(`No suitable zip options found for the ${process.platform} platform.`);
    return Promise.resolve()  ;
  }
 
  if (!zipPlatform.cmd) {
    console.error(`No suitable zip command found for the ${process.platform} platform.`);
    return Promise.resolve()  ;
  }
  
  let options = { encoding: 'utf-8' };
  if (zipPlatform.cd) {
    console.log(`Run zip in ${sourceFiles}`);
    options.cwd = sourceFiles;
    outputFile = path.relative(sourceFiles, outputFile);
  }

  let cmd = zipPlatform.cmd.replace(/\${zipSourceFiles}/g, sourceFiles);
  cmd = cmd.replace(/\${zipOutputFile}/g, outputFile);
  const cwd = zipPlatform.cwd || '.';
  console.log(`Zip command: ${cmd}`);


  return new Promise((resolve) => exec(cmd, options, (err, stdout, stderr) => {
    if (err) {
      console.error(`Failed to compress ${sourceFiles}: ${err.message}`);
    } else {
      console.log(stdout);
    }
    resolve();
  })); 
}

/**
 * Create a zip file.
 * @param {string} folderToZip - the directory containing the files to zip.
 * @param {string} zippedOutputDir - directory in which the fil should be placed.
 * @param {module:hcjeTools/build/build/ZipOptions} zipOptions - options for zip files from the configuration file.
 * @private
 */ 
function createZipFile(folderToZip, zippedOutputDir, zipOptions) {
  if (zippedOutputDir && zipOptions) {
    return fsPromises.mkdir(options.zippedOutputDir, {recursive: true})
      .then(() => {
        const zipName = (`${packageDetails.name}_${packageDetails.version}`
            .replace(/[.]/g, '_')).toLowerCase();
        return compressFolder(options.zipOptions, folderToZip, path.join(options.zippedOutputDir, zipName));
      });
  } else {
    console.log("No zip file created. To create a zip file both zippedOutputDir and zipOptions need to be set in the configuration file.");
    return Promise.resolve();
  }
}


/**
 * Details of the package. This will be taken from package.json
 * @type {Object}
 * @private
 */
let packageDetails;


// Execute build
if (process.argv.length < 3) {
  showUsageAndExit('Incorrect arguments.');
} 

let configFile = process.argv[2];
const NO_ZIP = /^--nozip$/i.test(process.argv[3]);

console.log(`Loading options from ${configFile}`);

let options;
let buildOutputDir;
let hcjeSubmoduleOutputDir;
let hcjeSubmoduleSourceDir;

const HCJE_DESTINATION_FOLDER_NAME = '_hcje'; 
const HCJE_SUBMODULE_NAME = 'html-css-js-engine';

fsPromises.readFile('package.json', {encoding: 'utf-8'})
  .then((json) => {
    packageDetails = JSON.parse(json);
  })
  .then(() => fsPromises.readFile(configFile, {encoding: 'utf-8'}))
  .then((json) => {
    options = JSON.parse(json);
    buildOutputDir = options.outputDir;
    if (options.subDir) {
      buildOutputDir = path.join(buildOutputDir, options.subDir);
    }
    hcjeSubmoduleSourceDir = path.join(options.root, HCJE_SUBMODULE_NAME, 'source', 'hcje');
    console.log(`Looking for submodule source in ${hcjeSubmoduleSourceDir}`);
    if (existsSync(hcjeSubmoduleSourceDir)) {
      console.log('Submodule found.');
      hcjeSubmoduleOutputDir = path.join(buildOutputDir, HCJE_DESTINATION_FOLDER_NAME);
    } else {
      console.log('No submodule found.');
    }
  })
  .then(() => {
    if (!/^\.\/(?:build|docs)$/.test(options.outputDir)) {
      throw new Error(`Only ./build or ./docs supported as build directories. Will not delete or build to ${options.outputDir}.`);
    }
    return removeDir(options.outputDir);
  })
  .then(() => fsPromises.mkdir(buildOutputDir, {recursive: true}))
  .then(() => {
    if (hcjeSubmoduleOutputDir) {
      mkdirSync(hcjeSubmoduleOutputDir)
      if (!options.parserConfig.html.replacments) {
        options.parserConfig.html.replacements = [];
      }
      options.parserConfig.html.replacements.push({
        pattern: new RegExp(`(\./)?${HCJE_SUBMODULE_NAME}/source/hcje/`, 'g'),
        replacement: `${HCJE_DESTINATION_FOLDER_NAME}/`
      });
    }
  })
  .then(() => {
    if (options.readme) {
      return copyFile(options.readme, options.outputDir, {
          parserConfig: options.parserConfig,
          packageDetails: packageDetails,
          targetFilename: 'README.md'
      })
    }
  })
  .then(() => {
    let includeFilesRegex;
    let excludeFilesRegex;
    let excludeDirsRegex;
    if (options.filter?.includeFiles) {
      includeFilesRegex = new RegExp(options.filter.includeFiles.regex, options.filter.includeFiles.flags);
    }
    if (options.filter?.excludeFiles) {
      excludeFilesRegex = new RegExp(options.filter.excludeFiles.regex, options.filter.excludeFiles.flags);
    }
    if (options.filter?.excludeDirs) {
      excludeDirsRegex = new RegExp(options.filter.excludeDirs.regex, options.filter.excludeDirs.flags);
    }
    const sourceDirs = [options.root];
    const targetDirs = [buildOutputDir];
    if (hcjeSubmoduleOutputDir) {
      sourceDirs.push(hcjeSubmoduleSourceDir);
      targetDirs.push(hcjeSubmoduleOutputDir);
    }
    return copyDirectories(sourceDirs, targetDirs, {
        includeFiles: includeFilesRegex,
        excludeFiles: excludeFilesRegex,
        excludeDirs: excludeDirsRegex,
        parserConfig: options.parserConfig,
        packageDetails: packageDetails
      });
  })
  .then(() => {
    if (NO_ZIP) {
      console.log(`Zip file creation switched off via --nozip option.`);
    } else { 
      return createZipFile(buildOutputDir, options.zippedOutputDir, options.zipOptions);
    }
  })
  .then(() => {
    console.log(`Build complete.`); 
  });