Manifest.mjs

import locale from "locale-codes";
import { globSync as glob } from "glob";
import path from "path";
import fs from "fs";
import deepmerge from "deepmerge";

/** 
 * A string, or string array, of "glob" paths describing files/folders.
 *
 * Excerpt from {@link https://github.com/mrmlnc/fast-glob/blob/master/README.md | fast-glob's} documentation, which is the library used to resolve file paths for all fields of this type:
 *
 * #### Basic syntax
 *
 *   * An asterisk (`*`) — matches everything except slashes (path separators), hidden files (names starting with `.`).
 *   * A double star or globstar (`**`) — matches zero or more directories.
 *   * Question mark (`?`) – matches any single character except slashes (path separators).
 *   * Sequence (`[seq]`) — matches any character in sequence.
 * 
 * @example 
`src/** /*.js` //matches all files in the `src` directory (any level of nesting) that have the `.js` extension. (space after `/**` due to formatting only, should not be included) 
`src/*.??` //matches all files in the `src` directory (only first level of nesting) that have a two-character extension.
`file-[01].js` //matches files: `file-0.js`, `file-1.js`.
 *
 * @typedef {String|Array<String>} globstring
 */

/**
 * Definitions for module-included compendium databases. If `String` forms are used, values will apply to all discovered compendiums. If `Object` fields are used, values should be `String:String` pairs keyed by the compendium's containing folder, which is used as its ID.
 *
 * @typedef {Object} CompendiaJSON
 * @prop {globstring} path Defines root folders for general, or specific database discovery
 * @prop {String|Object} type FoundryVTT Document type for discovered databases
 * @prop {String|Object} label Displayed name of compendium in FoundryVTT
 * @prop {String|Object} [banner] Asset path for compendium banner
 * @prop {String|Object} [system] associated system (if any) for compendiums
 * @prop {{label:String, color:?String}} [folder] Top level folder definition, color fields use hex-strings of the form `"#RRGGBB"`.
 */

/**
 * Top level definition of this module's root files, or entry points. Language files should follow the country code naming convention, e.g. `en.json` or `ja.json`, for proper detection.
 * All files/globstrings listed in this section, _except_ for compendia, will be watched for changes and trigger rebundles. Compendium entries are unpacked (if requested) prior to any cleaning operation of the output folder, and packed _after_ said cleaning operation.
 *
 * @typedef {Object} EntryPointJSON
 * @prop {globstring} main paths for top level module files (e.g. `init.mjs` or `[module name].mjs`)
 * @prop {globstring} [lang] paths for language files with `[country code].json` format (e.g. `en.json`)
 * @prop {globstring} [templates] paths for handlebars template files (.hbs or .html)
 * @prop {CompendiaJSON} [compendia] folder paths containing leveldb source files, with equivalent relative paths used as location for profile's built package databases
 */

/**
 * Defines if and how the resulting bundle should be packaged as a .zip file for distribution. Resulting archive is placed as a sibling of the profile's destination path.
 *
 * @typedef {Object} PackageJSON
 * @prop {Boolean} [create=false] Should the resulting bundle be zipped for distribution
 * @prop {String} [name] Base name of the resulting zip file, e.g. "cool-mod" -> "cool-mod.zip". Defaults to "[id]-[version]".
 * @prop {Boolean} [protected=false] Controls packing for premium modules and has the following side effects:
 *                                      - `manifest`: defaults to Foundry's premium content server unless specified otherwise
 *                                      - `download`: removes this field (if provided) as it is served from Foundry after DRM validation
 * @prop {String} [manifest=""] URL pointing to the most recent release's manifest file
 * @prop {String} [download=""] URL pointing to this specific release's distribution package (.zip file)
 */

/**
 * @typedef {Object} DenProfileJSON
 * @prop {String} [dest] output directory for resulting build, relative to this bd config file (overrides {@link DenConfigJSON.dest})
 * @prop {String} [id] top level identifier for module (overrides {@link DenConfigJSON.id})
 * @prop {String} [version] directly added to resulting manifest (overrides {@link DenConfigJSON.version})
 * @prop {Boolean} [compress] compress resulting json, (m)js, and css files (extremely conservative, but do confirm proper operation)
 * @prop {Boolean} [sourcemaps] generate sourcemaps for resulting (m)js and css files.
 * @prop {Boolean} [clean] clear contents of target `dest` directory before build (does not clean on watch)
 * @prop {Boolean} [hmr] enable hot reload functionality for html, css, hbs, and json files (overrides `clean` to false)
 * @prop {EntryPointJSON} [entryPoints] merged with top level `entryPoints` field; see {@link DenConfigJSON.entryPoints}
 * @prop {globstring} [static] merged with top level `static` field; see {@link DenConfigJSON.static}
 * @prop {PackageJSON} [package] Profile-specific packaging instructions, merged with top-level field.
 * @prop {Object} [flags] merged with top level `flags` field; see {@link DenConfigJSON.flags}
 */

/**
 * @typedef {Object} DenConfigJSON
 * @prop {String} [id] Top level identifier for module (default is BD file name, as `[id].bd.json`)
 * @prop {String} version Directly added to resulting manifest
 * @prop {String} title Directly added to resulting manifeste
 * @prop {String} description Directly added to resulting manifest
 * @prop {String} [projectUrl]
 * @prop {PackageJSON} [package] Global packaging instructions for all profiles. Overridden by profile entries.
 * @prop {EntryPointJSON} entryPoints
 * @prop {String} dest Output directory for resulting build, relative to this bd config file
 * @prop {globstring} [static] File/folder paths to be directly copied to built package _once_ upon initial bundle only (not 
 *                             re-copied on watch trigger)
 * @prop {Record<String, DenProfileJSON>} profile List of profile objects keyed by its name, such as 'release' or 'dev'
 * @prop {Object} [dependencies] Inner String arrays are treated as `[min, verified, max]` versions
 * @prop {Array<String>} [dependencies.core=[]]
 * @prop {Record<String,Array<String>>} [dependencies.modules={}] Module id to version array
 * @prop {Record<String,Array<String>>} [dependencies.systems={}] System id to version array
 * @prop {Object|Array<Object>} [authors] Directly added to resulting manifest
 * @prop {Object} [flags] Directy added to resulting manifest
 * @prop {Boolean} [socket=false] Directly added to resulting manifest -- automatically detected by presense
 *                                of 'game.socket' in bundled code.
 * @prop {Boolean} [storage=false] Directly added to resulting manifest as "persistentStorage" -- automatically
 *                                 detected by presense of 'uploadPersistent' in bundled code.
 */

const posixPath = (winPath) => winPath.split(path.sep).join(path.posix.sep);

const combineEntryPoints = (a = {}, b = {}) => {
  const ensureArray = (val) => (val instanceof Array ? val : [val]);
  const ensureArrayValues = (obj) =>
    Object.keys(obj).forEach((key) => {
      if (key == "compendia") obj[key]["path"] = ensureArray(obj[key]["path"]);
      else obj[key] = ensureArray(obj[key]);
    });
  [a, b].forEach(ensureArrayValues);
  return deepmerge(a, b);
};

// Flatten an object to dot notation
const flatten = (obj, roots = [], sep = ".") =>
  Object.keys(obj).reduce(
    (memo, prop) =>
      Object.assign(
        {},
        memo,
        Object.prototype.toString.call(obj[prop]) === "[object Object]"
          ? flatten(obj[prop], roots.concat([prop]), sep)
          : { [roots.concat([prop]).join(sep)]: obj[prop] }
      ),
    {}
  );

/**
 * Class which represents the data contained within a specific Badger Den config file. E.g. './rollup-config-badger-den.bd.json'.
 */
class BDConfig {
  #cache = { manifest: null, replacements: null };

  /* Selecte build profile within the config file */
  profile = null;

  /* Full config file */
  config = null;

  /* Replacement namespace */
  namespace = null;

  /**
   * Generate manifest data
   *
   * @constructor
   * @param {string} profileURI
   * @param {string} [namespace='config'] Top level namespace for which all fields inside the declared den config file are available in source code as '%namespace.path.to.field%'.
   */
  constructor(profileURI, namespace = "config") {
    profileURI = path.isAbsolute(profileURI)
      ? profileURI
      : path.join(process.env.INIT_CWD, profileURI);

    const { profile, config } = this.load(profileURI);
    this.config = config;
    this.profile = profile;
    this.templates = [];
    this.externals = [];
    this.namespace = namespace;
  }

  // Replacement paths for simple search/replace
  // TODO split paths and lookup recursively
  configReplacements = (prefix = this.namespace) => {
    const flat = flatten(this.config);
    const replacements = Object.keys(flat).reduce((acc, path) => {
      acc[`%${prefix}.${path}%`] = flat[path];
      return acc;
    }, {});

    const sConfig = JSON.stringify(this.config);
    this.config = JSON.parse(this.doReplace(sConfig, replacements));
    return replacements;
  };

  //TODO grab by /%((?\w+).?))+%/g
  doReplace = (targetString, replacements = this.#cache.replacements) => {
    //console.log('replacements', replacements);
    Object.entries(replacements).forEach(([key, val]) => {
      const regex = new RegExp(key, "g");
      targetString = targetString.replace(regex, () => val);
    });
    return targetString;
  };

  enumerateStatics(config = {}, profile = {}) {
    config.static ??= [];
    profile.static ??= [];

    if (typeof config.static == "string") config.static = [config.static];
    if (typeof profile.static == "string") profile.static = [profile.static];

    const globs = config.static
      .concat(profile.static)
      .map((path) => posixPath(path));

    const staticFiles = glob(globs, {
      cwd: profile.src,
      onlyFiles: true,
      unique: true,
      matchBase: true,
      posix: true,
      ignore: ["*.scss", "*.sw*", "*.tmp", "*.orig"],
    });
    return staticFiles;
  }

  makeEntryPointFields = (entryPoints, profile = this.profile) => {
    /* ES Modules */
    let esmodules = entryPoints.main ?? [];
    if (typeof esmodules == "string") esmodules = [esmodules];
    esmodules = esmodules.flatMap((entry) =>
      glob(entry, { cwd: profile.src })
        .filter((fp) => !!path.extname(fp))
        .map(posixPath)
    );

    /* Externals */
    let externals = entryPoints.external ?? [];
    if (typeof externals == "string") externals = [externals];
    externals = externals.flatMap((entry) =>
      glob(entry, { cwd: profile.src, onlyFiles: true }).map(posixPath)
    );

    /* Templates */
    let templates = entryPoints.templates ?? [];
    if (typeof templates == "string") templates = [templates];
    templates = templates.flatMap((entry) =>
      glob(entry, { cwd: profile.src, onlyFiles: true }).map(posixPath)
    );

    /* Compiled Styles */
    this.config.styleSources = glob("**/*.{scss,less,css}", {
      cwd: profile.src,
      onlyFiles: true,
      unique: true,
      gitignore: true,
    }).map(posixPath);
    const styles =
      this.config.styleSources.length > 0 ? [this.config.id + ".css"] : [];

    /* Discovered Languages */
    let languages = entryPoints.lang ?? [];
    if (typeof languages == "string") languages = [languages];
    languages = languages.flatMap((entry) => {
      const files = glob(entry, { cwd: profile.src }).filter(
        (fp) => !!path.extname(fp)
      );
      return files
        .map((filename) => {
          if (path.extname(filename) == `.json`) {
            const lang = path.basename(filename, ".json");
            const name = locale.getByTag(lang).name;
            return {
              lang,
              name,
              path: posixPath(
                path.relative(profile.src, path.join(profile.src, filename))
              ),
            };
          }
          return null;
        })
        .filter((lang) => !!lang);
    });

    /* Discovered document types */
    const defFiles = glob("**/*.bdt.json", { cwd: profile.src });
    const documentTypes = defFiles.reduce((acc, file) => {
      const fullPath = path.join(profile.src, file);
      const { type, ...def } = JSON.parse(fs.readFileSync(fullPath));

      const { base } = path.parse(fullPath);
      const id = base.split(".").at(0);
      acc[type] ??= {};
      acc[type][id] = def;

      return acc;
    }, {});

    /* Discovered compendium source folders */
    /* 1) Enumerate folders of provided paths
     * 3) Construct object of 'name' to {entry data}
     * 4) Grab all other keys inside 'compendia' root to insert into each entry value
     * 5) return as array of values in 'packs' field
     */
    const paths = entryPoints.compendia?.path ?? [];
    const packFolders = paths.flatMap((p) => {
      if (p.at(-1) != "/") p += "/";
      return glob(p, { cwd: profile.src });
    });

    const getPackValue = (name, property) => {
      const val = entryPoints.compendia[property];
      if (!val) return null;
      if (typeof val == "string") return val;

      return val[name];
    };

    const compendiumFields = Reflect.ownKeys(
      entryPoints.compendia ?? {}
    ).filter((key) => !key.includes("path"));

    const packs = packFolders.map((folder) => {
      const folderPath = posixPath(folder);
      const parsed = path.parse(folderPath);
      const { name } = parsed;

      parsed.base = parsed.name = name;
      const packPath = posixPath(path.format(parsed));

      //console.log(folderPath, parsed, packPath, type, name);

      const entry = { name, path: packPath };
      compendiumFields.forEach((opt) => {
        const val = getPackValue(name, opt);
        if (!!val) entry[opt] = val;
      });

      /* adjust path for images (CSS is a pain) */
      if ("banner" in entry) {
        entry["banner"] = `modules/${this.config.id}/${entry["banner"]}`;
      }

      return entry;
    });

    const folder = entryPoints.compendia?.folder ?? {};
    const packFolderEntries = [];
    if ("label" in folder) {
      packFolderEntries.push({
        name: folder.label,
        color: folder.color ?? "#000000",
        packs: packs.map((p) => p.name),
      });
    }

    const statics = this.enumerateStatics(this.config, this.profile);

    console.log("Entry Points:", [...esmodules, ...externals]);
    if (styles.length > 0)
      console.log("Discovered Styles:", this.config.styleSources);
    if (languages.length > 0)
      console.log(
        "Discovered Languages:",
        languages.map((lang) => `${lang.name} (${lang.path})`)
      );
    if (templates.length > 0) console.log("Discovered Templates:", templates);
    if (defFiles.length > 0)
      console.log(
        "Discovered Sub-Types:",
        Reflect.ownKeys(documentTypes).map(
          (type) => `${type}[${Reflect.ownKeys(documentTypes[type]).join(".")}]`
        )
      );
    if (packs.length > 0)
      console.log(
        "Discovered Compendia:",
        packs.map((p) => p.path)
      );
    if (packFolderEntries.length > 0)
      console.log(
        "Compendium Folders:",
        packFolderEntries.map((e) => `${e.name}: ${e.packs.join(", ")}`)
      );
    if (statics.length > 0) console.log("Static Files:", statics);

    return {
      esmodules,
      styles,
      languages,
      templates,
      packs,
      packFolders: packFolderEntries,
      documentTypes,
      externals,
      statics,
    };
  };

  compat = ([min, curr, max]) => {
    const data = {};
    !!min ? (data.minimum = min) : null;
    !!curr ? (data.verified = curr) : null;
    !!max ? (data.maximum = max) : null;
    return data;
  };

  makeDep = (deps) =>
    Object.entries(deps).map(([id, versions]) => ({
      id,
      compatibility: this.compat(versions),
    }));

  get cache() {
    return this.#cache;
  }

  /**
   * Parses the loaded bd config, constructing and caching the module
   * manifest data, as well as the namespace replacements.
   *
   * @param {boolean} [force=false]
   * @returns Object
   * @memberof BDConfig
   */
  build(force = false) {
    if (force)
      this.#cache = {
        manifest: null,
        replacements: null,
        statics: null,
        templates: null,
        externals: null,
      };
    else {
      this.#cache ??= {};
      this.#cache.manifest ??= null;
      this.#cache.replacements ??= null;
      this.#cache.statics ??= null;
      this.#cache.templates ??= null;
      this.#cache.externals ??= null;
    }

    this.#cache.replacements ??= this.configReplacements();

    if (Object.values(this.#cache).some((v) => !v)) {
      const { templates, externals, statics, ...entryPoints } =
        this.makeEntryPointFields(this.config.entryPoints);

      this.#cache.statics ??= statics;
      this.#cache.templates ??= templates;
      this.#cache.externals ??= externals;

      /** @type ManifestJSON */
      this.#cache.manifest ??= {
        id: this.config.id,
        title: this.config.title,
        version: this.config.version,
        description: this.config.description,
        authors: this.config.authors,
        url: this.config.projectUrl,
        compatibility: this.compat(this.config.dependencies.core),
        relationships: {
          systems: this.makeDep(this.config.dependencies.systems),
          modules: this.makeDep(this.config.dependencies.modules),
        },
        persistentStorage: !!this.config.storage,
        socket: !!this.config.socket,
        manifest: this.config.package.manifest,
        download: this.config.package.download,
        flags: this.config.flags,
        ...entryPoints,
      };

      // TODO dep 'profile.premium'
      if (this.profile.premium) {
        console.warn('[Deprecation: DenProfileJSON.premium] Use [DenConfigJSON|DenProfileJSON].package.protected instead. See PackageJSON.protected. Will be removed in version 2.0.');
        this.config.package.protected = true;
      }

      if (this.config.package.protected) {
        /* add default premium manifest path if none present */
        this.#cache.manifest.manifest = this.config.package.manifest ? this.config.package.manifest : `https://r2.foundryvtt.com/packages-public/${this.config.id}/module.json`;
        delete this.#cache.manifest.download;
        this.#cache.manifest.protected = true;
      }
    }

    return this.#cache;
  }

  modifyManifest(key, value) {
    if (!this.#cache.manifest) {
      throw new Error('Cannot modify unbuilt manifest. Run "build()" first.');
    }

    return (this.#cache.manifest[key] = value);
  }

  makeFlags(config, profile) {
    /* grab any direct flags defined */
    const global = config.flags ?? {};
    let local = profile.flags ?? {};

    /* grab predefined profile switches */
    if (!!profile.hmr) {
      const predef = {
        hotReload: {
          extensions: ["css", "html", "hbs", "json"],
        },
      };
      local = deepmerge(local, predef);
    }

    return deepmerge(global, local);
  }

  /**
   * Reads declared config file and preparing neccessary data
   * for module building operations.
   *
   * @param {string} profileURI
   * @returns {{profile: Object, config: Object}}
   * @memberof BDConfig
   */
  load(profileURI) {
    const configRel = path.dirname(profileURI);
    const nameProfile = path.basename(profileURI);
    const [configName = null, profileName = null] = nameProfile.split(":");

    if (!(configRel && configName && profileName)) {
      throw new Error(
        `${profileURI} den config cannot be parsed as "[path]/[config]:[profile]", received: ${configRel}/${configName}:${profileName}`
      );
    }

    /* load package config */
    const configPath = path.join(configRel, configName + ".bd.json");

    if (!fs.existsSync(configPath)) {
      throw new Error(
        `Could not locate den config file. Provided URI = "${profileURI}". Localized to "${configPath}" from "${
          import.meta.url
        }".`
      );
    }

    /** @type DenConfigJSON */
    const config = JSON.parse(fs.readFileSync(configPath));

    /* latch desired profile */
    if (!(profileName in config.profile ?? {})) {
      throw new Error(
        `Could not locate den config field "profile.${profileName}" in "${configPath}"`
      );
    }

    const profile = config.profile[profileName];
    const moduleRoot = path.dirname(configPath);

    config.name = configName;

    profile.src = moduleRoot;
    profile.name = profileName;
    profile.flags ??= {};

    /* allow profiles to override module ID */
    config.id = profile.id ?? config.id ?? configName;

    /* allow main config to define default output destination */
    profile.dest ??= config.dest;

    if (!profile.dest) {
      throw new Error(
        `No destination ("dest") found for profile ${profileName}`
      );
    }

    if (!path.isAbsolute(profile.dest))
      profile.dest = path.resolve(path.join(configRel, profile.dest));

    /* Sanity check to make sure parent directory of the 
     * module (i.e. profile.dest) exists. */
    if (!fs.existsSync(profile.dest)) {
      fs.mkdirSync(profile.dest, { recursive: true });
    }

    /* Final resting place is defined 'destination' + packageID */
    profile.dest = path.join(profile.dest, config.id);

    config.dependencies ??= {};
    config.dependencies.core ??= [];
    config.dependencies.systems ??= {};
    config.dependencies.modules ??= {};
    config.authors ??= [];

    /* merge profile-based overrides into config */
    config.entryPoints = combineEntryPoints(
      config.entryPoints,
      profile.entryPoints
    );

    config.flags = this.makeFlags(config, profile);
    config.version = profile.version ?? config.version;

    this.config = config;
    this.profile = profile;

    this.config.package = deepmerge.all([
      {
        name: `${this.config.id}-${this.config.version}`,
        create: false,
        protected: false,
        manifest: "",
        download: "",
      },
      this.config.package ?? {},
      this.profile.package ?? {},
    ]);

    return { profile, config };
  }

  get styleSources() {
    return this.config.styleSources.map((source) =>
      this.makeInclude(this.profile.src, source)
    );
  }

  makeInclude(root, target) {
    return posixPath(path.join(path.relative(root, this.profile.src), target));
  }
}

export default BDConfig;