Source

cli/commands/build-scripts.ts

import { Command } from "../command";
import { CommandOptions } from "../types";
import { DefaultCommandOptions, DefaultCommandValues } from "../constants";
import {
  copyFile,
  deletePath,
  getAllFiles,
  getPackage,
  patchFile,
  runCommand,
  getFileSizeZipped,
  listNodeModulesPackages,
} from "../../utils";
import fs from "fs";
import path from "path";
import type { InputOptions, OutputOptions, RollupBuild } from "rollup";
import typescript from "@rollup/plugin-typescript";
import commonjs from "@rollup/plugin-commonjs";
import { nodeResolve } from "@rollup/plugin-node-resolve";
import json from "@rollup/plugin-json";
import { execSync } from "child_process";
import { builtinModules } from "module";
import { LoggingConfig, LogLevel } from "@decaf-ts/logging";

// declare optional terser module to satisfy TypeScript when types aren't installed
declare module "@rollup/plugin-terser";

import * as ts from "typescript";
import {
  Diagnostic,
  EmitResult,
  ModuleKind,
  ModuleResolutionKind,
  SourceFile,
} from "typescript";

export function parseList(input?: string | string[]): string[] {
  if (!input) return [];
  if (Array.isArray(input))
    return input.map((i) => `${i}`.trim()).filter(Boolean);
  return `${input}`
    .split(",")
    .map((p) => p.trim())
    .filter(Boolean);
}

export function packageToGlobal(name: string): string {
  // Remove scope and split by non-alphanumeric chars, then camelCase
  const withoutScope = name.replace(/^@/, "");
  const parts = withoutScope.split(/[/\-_.]+/).filter(Boolean);
  return parts
    .map((p, i) =>
      i === 0
        ? p.replace(/[^a-zA-Z0-9]/g, "")
        : `${p.charAt(0).toUpperCase()}${p.slice(1)}`
    )
    .join("");
}

export function getPackageDependencies(): string[] {
  // Try the current working directory first
  let pkg: any;
  try {
    pkg = getPackage(process.cwd()) as any;
  } catch {
    pkg = undefined;
  }

  // If no dependencies found in cwd, try the package next to this source file (fallback for tests)
  try {
    const hasDeps =
      pkg &&
      (Object.keys(pkg.dependencies || {}).length > 0 ||
        Object.keys(pkg.devDependencies || {}).length > 0 ||
        Object.keys(pkg.peerDependencies || {}).length > 0);
    if (!hasDeps) {
      const fallbackDir = path.resolve(__dirname, "../../..");
      try {
        pkg = getPackage(fallbackDir) as any;
      } catch {
        // ignore and keep pkg as-is
      }
    }
  } catch {
    // ignore
  }

  const deps = Object.keys((pkg && pkg.dependencies) || {});
  const peer = Object.keys((pkg && pkg.peerDependencies) || {});
  const dev = Object.keys((pkg && pkg.devDependencies) || {});
  return Array.from(new Set([...deps, ...peer, ...dev]));
}

// eslint-disable-next-line @typescript-eslint/no-unused-vars
function buildExportsTypePathMappings(
  cwd: string = process.cwd(),
  deps: string[] = getPackageDependencies()
): Record<string, string[]> {
  const mappings: Record<string, string[]> = {};

  for (const dep of deps) {
    const pkgPath = path.join(cwd, "node_modules", dep, "package.json");
    if (!fs.existsSync(pkgPath)) continue;

    let pkg: any;
    try {
      pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
    } catch {
      continue;
    }

    const exportsField = pkg?.exports;
    if (!exportsField || typeof exportsField !== "object") continue;

    for (const [subpath, target] of Object.entries(exportsField)) {
      if (!target || typeof target !== "object") continue;
      const typesPath = (target as any).types;
      if (typeof typesPath !== "string" || !typesPath.length) continue;

      const normalizedSubpath = String(subpath).replace(/^\.\//, "");
      const normalizedSpecifier =
        subpath === "." ? dep : `${dep}/${normalizedSubpath}`;
      const normalizedTypesPath = `./node_modules/${dep}/${typesPath.replace(/^\.\//, "")}`;
      mappings[normalizedSpecifier] = [normalizedTypesPath];

      // Mirror wildcard export mappings so TypeScript can resolve deep import types.
      if (
        normalizedSubpath.endsWith("/*") &&
        normalizedTypesPath.endsWith("/*")
      ) {
        const specifierNoWildcard = normalizedSpecifier.slice(0, -2);
        const typesNoWildcard = normalizedTypesPath.slice(0, -2);
        if (specifierNoWildcard && typesNoWildcard) {
          mappings[specifierNoWildcard] = [typesNoWildcard];
        }
      }
    }
  }

  return mappings;
}

const VERSION_STRING = "##VERSION##";
const COMMIT_STRING = "##COMMIT##";
const FULL_VERSION_STRING = "##FULL_VERSION##";
const PACKAGE_STRING = "##PACKAGE##";
const PACKAGE_SIZE_STRING = "##PACKAGE_SIZE##";

enum Modes {
  CJS = "commonjs",
  ESM = "es2022",
}

enum BuildMode {
  BUILD = "build",
  BUNDLE = "bundle",
  ALL = "all",
}

enum TsBuildTarget {
  ESM = "esm",
  CJS_CHECK = "cjs-check",
  TYPES = "types",
  NODE_NEXT_VALIDATE = "nodenext-validate",
  BUNDLE = "bundle",
}

const options = {
  prod: {
    type: "boolean",
    default: false,
  },
  dev: {
    type: "boolean",
    default: false,
  },
  buildMode: {
    type: "string",
    default: BuildMode.ALL,
  },
  includes: {
    type: "string",
    default: "",
  },
  externals: {
    type: "string",
    default: "",
  },
  docs: {
    type: "boolean",
    default: false,
  },
  commands: {
    type: "boolean",
    default: false,
  },
  entry: {
    type: "string",
    default: "./src/index.ts",
  },
  banner: {
    type: "boolean",
    default: false,
  },
  validateNodeNext: {
    type: "boolean",
    default: false,
  },
};

const cjs2Transformer = (ext = ".cjs") => {
  const log = BuildScripts.log.for(cjs2Transformer);
  const resolutionCache = new Map<string, string>();

  return (transformationContext: ts.TransformationContext) => {
    return (sourceFile: ts.SourceFile) => {
      const sourceDir = path.dirname(sourceFile.fileName);

      function resolvePath(importPath: string) {
        const cacheKey = JSON.stringify([sourceDir, importPath]);
        const cachedValue = resolutionCache.get(cacheKey);
        if (cachedValue != null) return cachedValue;

        let resolvedPath = importPath;
        try {
          resolvedPath = path.resolve(sourceDir, resolvedPath + ".ts");
        } catch (error: unknown) {
          throw new Error(`Failed to resolve path ${importPath}: ${error}`);
        }
        let stat;
        try {
          stat = fs.statSync(resolvedPath);
        } catch (e: unknown) {
          try {
            log.verbose(
              `Testing existence of path ${resolvedPath} as a folder defaulting to index file`
            );
            stat = fs.statSync(resolvedPath.replace(/\.ts$/gm, ""));
          } catch (e2: unknown) {
            throw new Error(
              `Failed to resolve path ${importPath}: ${e}, ${e2}`
            );
          }
        }
        if (stat.isDirectory())
          resolvedPath = resolvedPath.replace(/\.ts$/gm, "/index.ts");

        if (path.isAbsolute(resolvedPath)) {
          const extension =
            (/\.tsx?$/.exec(path.basename(resolvedPath)) || [])[0] || void 0;

          resolvedPath =
            "./" +
            path.relative(
              sourceDir,
              path.resolve(
                path.dirname(resolvedPath),
                path.basename(resolvedPath, extension) + ext
              )
            );
        }

        resolutionCache.set(cacheKey, resolvedPath);
        return resolvedPath;
      }

      function visitNode(node: ts.Node): ts.VisitResult<ts.Node> {
        if (shouldMutateModuleSpecifier(node)) {
          if (ts.isImportDeclaration(node)) {
            const resolvedPath = resolvePath(node.moduleSpecifier.text);
            const newModuleSpecifier =
              transformationContext.factory.createStringLiteral(resolvedPath);
            return transformationContext.factory.updateImportDeclaration(
              node,
              node.modifiers,
              node.importClause,
              newModuleSpecifier,
              undefined
            );
          } else if (ts.isExportDeclaration(node)) {
            const resolvedPath = resolvePath(node.moduleSpecifier.text);
            const newModuleSpecifier =
              transformationContext.factory.createStringLiteral(resolvedPath);
            return transformationContext.factory.updateExportDeclaration(
              node,
              node.modifiers,
              node.isTypeOnly,
              node.exportClause,
              newModuleSpecifier,
              undefined
            );
          }
        } else if (ts.isCallExpression(node)) {
          const moduleSpecifier = getCallExpressionModuleSpecifier(node);
          if (
            moduleSpecifier &&
            isRelativePathWithoutExtension(moduleSpecifier.text)
          ) {
            const resolvedPath = resolvePath(moduleSpecifier.text);
            const newModuleSpecifier =
              transformationContext.factory.createStringLiteral(resolvedPath);
            const updatedArguments = node.arguments.map((arg, index) =>
              index === 0 ? newModuleSpecifier : arg
            );
            return transformationContext.factory.updateCallExpression(
              node,
              node.expression,
              node.typeArguments,
              transformationContext.factory.createNodeArray(updatedArguments)
            );
          }
        }

        return ts.visitEachChild(node, visitNode, transformationContext);
      }

      function shouldMutateModuleSpecifier(node: ts.Node): node is (
        | ts.ImportDeclaration
        | ts.ExportDeclaration
      ) & {
        moduleSpecifier: ts.StringLiteral;
      } {
        if (!ts.isImportDeclaration(node) && !ts.isExportDeclaration(node))
          return false;

        if (node.moduleSpecifier === undefined) return false;
        // only when module specifier is valid
        if (!ts.isStringLiteral(node.moduleSpecifier)) return false;
        return isRelativePathWithoutExtension(node.moduleSpecifier.text);
      }

      function isRelativePathWithoutExtension(rawPath: string) {
        if (!rawPath.startsWith("./") && !rawPath.startsWith("../"))
          return false;
        return path.extname(rawPath) === "";
      }

      function getCallExpressionModuleSpecifier(
        node: ts.CallExpression
      ): ts.StringLiteral | undefined {
        if (
          isDynamicImportCall(node) &&
          node.arguments.length > 0 &&
          ts.isStringLiteral(node.arguments[0])
        ) {
          return node.arguments[0];
        }
        if (
          ts.isIdentifier(node.expression) &&
          node.expression.text === "require" &&
          node.arguments.length > 0 &&
          ts.isStringLiteral(node.arguments[0])
        ) {
          return node.arguments[0];
        }
        return undefined;
      }

      function isDynamicImportCall(node: ts.CallExpression) {
        return node.expression.kind === ts.SyntaxKind.ImportKeyword;
      }

      return ts.visitNode(sourceFile, visitNode) as SourceFile;
    };
  };
};

/**
 * @description A command-line script for building and bundling TypeScript projects.
 * @summary This class provides a comprehensive build script that handles TypeScript compilation,
 * bundling with Rollup, and documentation generation. It supports different build modes
 * (development, production), module formats (CJS, ESM), and can be extended with custom
 * configurations.
 * @class BuildScripts
 */
export class BuildScripts extends Command<
  CommandOptions<typeof options>,
  void
> {
  private replacements: Record<string, string> = {};
  private readonly pkgVersion: string;
  private readonly pkgName: string;
  private readonly commitHash: string;
  private readonly fullVersion: string;

  constructor() {
    super(
      "BuildScripts",
      Object.assign({}, DefaultCommandOptions, options) as CommandOptions<
        typeof options
      >
    );
    const pkg = getPackage() as { name: string; version: string };
    const { name, version } = pkg;
    this.pkgName = name.includes("@") ? name.split("/")[1] : name;
    this.pkgVersion = version;
    try {
      this.commitHash = execSync("git rev-parse --short HEAD", {
        encoding: "utf8",
      }).trim();
    } catch {
      this.commitHash = "unknown";
    }
    this.fullVersion = `${this.pkgVersion}-${this.commitHash}`;
    this.replacements[VERSION_STRING] = this.pkgVersion;
    this.replacements[COMMIT_STRING] = this.commitHash;
    this.replacements[FULL_VERSION_STRING] = this.fullVersion;
    this.replacements[PACKAGE_STRING] = name;
  }

  /**
   * @description Patches files with version, commit, full version, and package name.
   * @summary This method reads all files in a directory, finds placeholders for version,
   * commit hash, full version, and package name, and replaces them with the actual values
   * from package.json and the current git revision.
   * @param {string} p - The path to the directory containing the files to patch.
   */
  patchFiles(p: string) {
    const log = this.log.for(this.patchFiles);
    const { name, version } = getPackage() as any;
    log.info(`Patching ${name} ${version} module in ${p}...`);
    const stat = fs.statSync(p);
    const patchVersionAndPackage = (content: string) => {
      let patched = content;
      // Patch public VERSION assignments without mutating internal VERSION_STRING constants.
      patched = patched.replace(
        /((?:^|[\s;,(])(?:const|let|var)\s+VERSION\s*=\s*["'])##VERSION##(["'])/gm,
        `$1${version}$2`
      );
      patched = patched.replace(
        /((?:^|[\s;,(])(?:const|let|var)\s+COMMIT\s*=\s*["'])##COMMIT##(["'])/gm,
        `$1${this.commitHash}$2`
      );
      patched = patched.replace(
        /((?:^|[\s;,(])(?:const|let|var)\s+FULL_VERSION\s*=\s*["'])##FULL_VERSION##(["'])/gm,
        `$1${this.fullVersion}$2`
      );
      patched = patched.replace(
        /((?:^|[\s;,(])(?:exports|module\.exports)\.VERSION\s*=\s*["'])##VERSION##(["'])/gm,
        `$1${version}$2`
      );
      patched = patched.replace(
        /((?:^|[\s;,(])(?:exports|module\.exports)\.COMMIT\s*=\s*["'])##COMMIT##(["'])/gm,
        `$1${this.commitHash}$2`
      );
      patched = patched.replace(
        /((?:^|[\s;,(])(?:exports|module\.exports)\.FULL_VERSION\s*=\s*["'])##FULL_VERSION##(["'])/gm,
        `$1${this.fullVersion}$2`
      );
      patched = patched.replace(
        /((?:^|[\s;,(])\w+\.VERSION\s*=\s*["'])##VERSION##(["'])/gm,
        `$1${version}$2`
      );
      patched = patched.replace(
        /((?:^|[\s;,(])\w+\.COMMIT\s*=\s*["'])##COMMIT##(["'])/gm,
        `$1${this.commitHash}$2`
      );
      patched = patched.replace(
        /((?:^|[\s;,(])\w+\.FULL_VERSION\s*=\s*["'])##FULL_VERSION##(["'])/gm,
        `$1${this.fullVersion}$2`
      );
      patched = patched.replace(
        /((?:^|[\s;,(])(?:const|let|var)\s+PACKAGE_NAME\s*=\s*["'])##PACKAGE##(["'])/gm,
        `$1${name}$2`
      );
      patched = patched.replace(
        /((?:^|[\s;,(])(?:exports|module\.exports)\.PACKAGE_NAME\s*=\s*["'])##PACKAGE##(["'])/gm,
        `$1${name}$2`
      );
      patched = patched.replace(
        /((?:^|[\s;,(])\w+\.PACKAGE_NAME\s*=\s*["'])##PACKAGE##(["'])/gm,
        `$1${name}$2`
      );
      return patched;
    };
    if (stat.isDirectory())
      fs.readdirSync(p, { withFileTypes: true, recursive: true })
        .filter((p) => p.isFile())
        .forEach((file) => {
          const filePath = path.join(file.parentPath, file.name);
          const content = fs.readFileSync(filePath, "utf8");
          const patched = patchVersionAndPackage(content);
          if (patched !== content) fs.writeFileSync(filePath, patched, "utf8");
          patchFile(
            filePath,
            Object.entries(this.replacements).reduce(
              (acc: Record<string, any>, [key, val]) => {
                if (
                  [VERSION_STRING, COMMIT_STRING, FULL_VERSION_STRING, PACKAGE_STRING].includes(
                    key
                  )
                )
                  return acc;
                acc[key] = val;
                return acc;
              },
              {}
            )
          );
        });
    log.verbose(`Module ${name} ${version} patched in ${p}...`);
  }

  private reportDiagnostics(
    diagnostics: Diagnostic[],
    logLevel: LogLevel
  ): string {
    const msg = this.formatDiagnostics(diagnostics);
    try {
      this.log[logLevel](msg);
    } catch (e: unknown) {
      console.warn(`Failed to get logger for ${logLevel}`);
      throw e;
    }
    return msg;
  }

  // Format diagnostics into a single string for throwing or logging
  private formatDiagnostics(diagnostics: Diagnostic[]): string {
    return diagnostics
      .map((diagnostic) => {
        let message = "";
        if (diagnostic.file && diagnostic.start) {
          const { line, character } =
            diagnostic.file.getLineAndCharacterOfPosition(diagnostic.start);
          message += `${diagnostic.file.fileName} (${line + 1},${character + 1})`;
        }
        message +=
          ": " + ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n");
        return message;
      })
      .join("\n");
  }

  private readConfigFile(configFileName: string) {
    // Read config file
    const configFileText = fs.readFileSync(configFileName).toString();

    // Parse JSON, after removing comments. Just fancier JSON.parse
    const result = ts.parseConfigFileTextToJson(configFileName, configFileText);
    const configObject = result.config;
    if (!configObject) {
      this.reportDiagnostics([result.error!], LogLevel.error);
    }

    // Extract config infromation
    const configParseResult = ts.parseJsonConfigFileContent(
      configObject,
      ts.sys,
      path.dirname(configFileName)
    );
    if (configParseResult.errors.length > 0)
      this.reportDiagnostics(configParseResult.errors, LogLevel.error);

    return configParseResult;
  }

  private evalDiagnostics(diagnostics: Diagnostic[]) {
    if (diagnostics && diagnostics.length > 0) {
      const errors = diagnostics.filter(
        (d) => d.category === ts.DiagnosticCategory.Error
      );
      const warnings = diagnostics.filter(
        (d) => d.category === ts.DiagnosticCategory.Warning
      );
      const suggestions = diagnostics.filter(
        (d) => d.category === ts.DiagnosticCategory.Suggestion
      );
      const messages = diagnostics.filter(
        (d) => d.category === ts.DiagnosticCategory.Message
      );
      // Log diagnostics to console

      if (warnings.length) this.reportDiagnostics(warnings, LogLevel.warn);
      if (errors.length) {
        this.reportDiagnostics(diagnostics as Diagnostic[], LogLevel.error);
        throw new Error(
          `TypeScript reported ${diagnostics.length} diagnostic(s) during check; aborting.`
        );
      }
      if (suggestions.length)
        this.reportDiagnostics(suggestions, LogLevel.info);
      if (messages.length) this.reportDiagnostics(messages, LogLevel.info);
    }
  }

  private preCheckDiagnostics(program: ts.Program) {
    const diagnostics = ts.getPreEmitDiagnostics(program);
    this.evalDiagnostics(diagnostics as any);
  }

  // Create a TypeScript program for the current tsconfig and fail if there are any error diagnostics.
  private async checkTsDiagnostics(
    isDev: boolean,
    mode: Modes,
    bundle = false
  ) {
    const log = this.log.for(this.checkTsDiagnostics);
    let tsConfig;
    try {
      tsConfig = this.readConfigFile("./tsconfig.json");
    } catch (e: unknown) {
      throw new Error(`Failed to parse tsconfig.json: ${e}`);
    }

    this.applyTsConfigProfile(
      tsConfig.options,
      bundle
        ? TsBuildTarget.BUNDLE
        : mode === Modes.ESM
          ? TsBuildTarget.ESM
          : TsBuildTarget.CJS_CHECK,
      isDev
    );

    const program = ts.createProgram(tsConfig.fileNames, tsConfig.options);
    this.preCheckDiagnostics(program);
    log.verbose(
      `TypeScript checks passed (${bundle ? "bundle" : "normal"} mode).`
    );
  }

  private async buildTs(isDev: boolean, mode: Modes, bundle = false) {
    const log = this.log.for(this.buildTs);
    log.info(
      `Building ${this.pkgName} ${this.pkgVersion} module (${mode}) in ${isDev ? "dev" : "prod"} mode...`
    );
    let tsConfig;
    try {
      tsConfig = this.readConfigFile("./tsconfig.json");
    } catch (e: unknown) {
      throw new Error(`Failed to parse tsconfig.json: ${e}`);
    }

    this.applyTsConfigProfile(
      tsConfig.options,
      bundle
        ? TsBuildTarget.BUNDLE
        : mode === Modes.ESM
          ? TsBuildTarget.ESM
          : TsBuildTarget.CJS_CHECK,
      isDev
    );

    // For production builds we still keep TypeScript comments (removeComments=false in tsconfig)
    // Bundler/terser will strip comments for production bundles as requested.

    const program = ts.createProgram(tsConfig.fileNames, tsConfig.options);

    const transformations: { before?: any[] } = {};
    if (mode === Modes.ESM) {
      transformations.before = [cjs2Transformer(".js")];
    }

    const emitResult: EmitResult = program.emit(
      undefined,
      undefined,
      undefined,
      undefined,
      transformations
    );

    const allDiagnostics = ts
      .getPreEmitDiagnostics(program)
      .concat(emitResult.diagnostics);

    this.evalDiagnostics(allDiagnostics);
  }

  private async build(isDev: boolean, mode: Modes, bundle = false) {
    const log = this.log.for(this.build);
    await this.buildTs(isDev, mode, bundle);

    log.verbose(
      `Module ${this.pkgName} ${this.pkgVersion} (${mode}) built in ${isDev ? "dev" : "prod"} mode...`
    );
    if (mode === Modes.CJS && !bundle) await this.buildCjsFromEsm(isDev);
  }

  private rewriteRelativeJsSpecifiersToCjs(content: string) {
    const replaceSpecifier = (specifier: string) => {
      if (
        !specifier.startsWith("./") &&
        !specifier.startsWith("../") &&
        !specifier.startsWith("/")
      )
        return specifier;
      if (specifier.endsWith(".cjs")) return specifier;
      if (specifier.endsWith(".js")) return specifier.replace(/\.js$/, ".cjs");
      return specifier;
    };

    const quotedSpecifierRegex = /(["'])(\.{1,2}\/[^"']+?)(\1)/g;
    return content.replace(
      quotedSpecifierRegex,
      (_full, quote: string, specifier: string, endQuote: string) =>
        `${quote}${replaceSpecifier(specifier)}${endQuote}`
    );
  }

  private async buildCjsFromEsm(isDev: boolean) {
    const log = this.log.for(this.buildCjsFromEsm);
    log.info(
      `Building ${this.pkgName} ${this.pkgVersion} module (${Modes.CJS}) from ESM output in ${isDev ? "dev" : "prod"} mode...`
    );

    const esmRoot = path.resolve("lib/esm");
    const cjsRoot = path.resolve("lib/cjs");
    fs.mkdirSync(cjsRoot, { recursive: true });

    const esmJsFiles = getAllFiles(
      esmRoot,
      (file) => file.endsWith(".js") && !file.endsWith(".d.js")
    );

    for (const file of esmJsFiles) {
      const relative = path.relative(esmRoot, file);
      const outFile = path.join(cjsRoot, relative).replace(/\.js$/gm, ".cjs");
      fs.mkdirSync(path.dirname(outFile), { recursive: true });

      const source = fs.readFileSync(file, "utf8");
      const transpiled = ts.transpileModule(source, {
        compilerOptions: {
          module: ModuleKind.CommonJS,
          target: ts.ScriptTarget.ES2022,
          sourceMap: !isDev,
          inlineSourceMap: isDev,
          inlineSources: isDev,
          esModuleInterop: true,
        },
        fileName: path.basename(file),
        reportDiagnostics: true,
      });

      if (transpiled.diagnostics?.length) {
        this.evalDiagnostics(transpiled.diagnostics as Diagnostic[]);
      }

      const rewritten = this.rewriteRelativeJsSpecifiersToCjs(
        transpiled.outputText
      );
      fs.writeFileSync(outFile, rewritten, "utf8");

      if (transpiled.sourceMapText) {
        fs.writeFileSync(`${outFile}.map`, transpiled.sourceMapText, "utf8");
      }
    }
  }

  private applyTsConfigProfile(
    options: ts.CompilerOptions,
    target: TsBuildTarget,
    isDev: boolean
  ) {
    options.declaration = false;
    options.emitDeclarationOnly = false;
    options.noEmit = false;
    options.outFile = undefined;
    options.moduleResolution = ModuleResolutionKind.Bundler;

    switch (target) {
      case TsBuildTarget.ESM:
        options.module = ModuleKind.ESNext;
        options.outDir = "lib/esm";
        break;
      case TsBuildTarget.CJS_CHECK:
        options.module = (ModuleKind as any).Preserve ?? ModuleKind.ESNext;
        options.moduleResolution = ModuleResolutionKind.Bundler;
        options.noEmit = true;
        options.outDir = undefined;
        break;
      case TsBuildTarget.TYPES:
        options.module = ModuleKind.ESNext;
        options.outDir = "lib/types";
        options.declaration = true;
        options.emitDeclarationOnly = true;
        break;
      case TsBuildTarget.NODE_NEXT_VALIDATE:
        options.module = ModuleKind.NodeNext;
        options.moduleResolution = ModuleResolutionKind.NodeNext;
        options.noEmit = true;
        break;
      case TsBuildTarget.BUNDLE:
        options.module = ModuleKind.ESNext;
        options.moduleResolution = ModuleResolutionKind.Bundler;
        options.outDir = "dist";
        options.isolatedModules = false;
        options.outFile = undefined;
        break;
    }

    if (target === TsBuildTarget.NODE_NEXT_VALIDATE) {
      options.inlineSourceMap = false;
      options.inlineSources = false;
      options.sourceMap = false;
      return;
    }

    if (isDev) {
      options.inlineSourceMap = true;
      options.inlineSources = true;
      options.sourceMap = false;
    } else {
      options.inlineSourceMap = false;
      options.inlineSources = false;
      options.sourceMap = true;
    }
  }

  private async checkNodeNextCompatibility() {
    const log = this.log.for(this.checkNodeNextCompatibility);
    let tsConfig;
    try {
      tsConfig = this.readConfigFile("./tsconfig.json");
    } catch (e: unknown) {
      throw new Error(`Failed to parse tsconfig.json: ${e}`);
    }

    this.applyTsConfigProfile(
      tsConfig.options,
      TsBuildTarget.NODE_NEXT_VALIDATE,
      false
    );

    const program = ts.createProgram(tsConfig.fileNames, tsConfig.options);
    this.preCheckDiagnostics(program);
    log.verbose("TypeScript NodeNext compatibility check passed.");
  }

  private async buildTypes(isDev: boolean) {
    const log = this.log.for(this.buildTypes);
    log.info(
      `Building ${this.pkgName} ${this.pkgVersion} declaration files...`
    );
    let tsConfig;
    try {
      tsConfig = this.readConfigFile("./tsconfig.json");
    } catch (e: unknown) {
      throw new Error(`Failed to parse tsconfig.json: ${e}`);
    }

    this.applyTsConfigProfile(tsConfig.options, TsBuildTarget.TYPES, isDev);

    const program = ts.createProgram(tsConfig.fileNames, tsConfig.options);
    const emitResult = program.emit();
    const allDiagnostics = ts
      .getPreEmitDiagnostics(program)
      .concat(emitResult.diagnostics);
    this.evalDiagnostics(allDiagnostics);
    this.emitDualDeclarationFiles();
    this.removeLegacyDeclarationFiles();
    this.updatePackageJsonDualTypeExports();
  }

  private rewriteRelativeDeclarationSpecifiers(
    content: string,
    declarationExtension: ".d.mts" | ".d.cts",
    sourceFilePath: string
  ) {
    const sourceDir = path.dirname(sourceFilePath);
    const withDeclarationSpecifier = (specifier: string) => {
      if (
        !specifier.startsWith("./") &&
        !specifier.startsWith("../") &&
        !specifier.startsWith("/")
      )
        return specifier;
      if (/\.(d\.(mts|cts)|mts|cts|ts|json)$/i.test(specifier))
        return specifier;
      const resolved = path.resolve(sourceDir, specifier);
      try {
        if (fs.existsSync(resolved) && fs.statSync(resolved).isDirectory()) {
          return `${specifier}/index${declarationExtension}`;
        }
      } catch {
        // ignore and fallback to file specifier
      }
      return `${specifier}${declarationExtension}`;
    };

    let updated = content.replace(
      /(\b(?:import|export)\b[\s\S]*?\bfrom\s*["'])([^"']+)(["'])/gm,
      (_full, prefix: string, specifier: string, suffix: string) =>
        `${prefix}${withDeclarationSpecifier(specifier)}${suffix}`
    );
    updated = updated.replace(
      /(\bimport\s*\(\s*["'])([^"']+)(["']\s*\))/gm,
      (_full, prefix: string, specifier: string, suffix: string) =>
        `${prefix}${withDeclarationSpecifier(specifier)}${suffix}`
    );
    updated = updated.replace(
      /(\brequire\s*\(\s*["'])([^"']+)(["']\s*\))/gm,
      (_full, prefix: string, specifier: string, suffix: string) =>
        `${prefix}${withDeclarationSpecifier(specifier)}${suffix}`
    );
    return updated;
  }

  private emitDualDeclarationFiles() {
    const log = this.log.for(this.emitDualDeclarationFiles);
    const typesRoot = path.resolve("lib/types");
    if (!fs.existsSync(typesRoot)) return;

    const typeFiles = getAllFiles(typesRoot, (file) => file.endsWith(".d.ts"));
    for (const dtsFile of typeFiles) {
      const content = fs.readFileSync(dtsFile, "utf8");
      const dMts = dtsFile.replace(/\.d\.ts$/i, ".d.mts");
      const dCts = dtsFile.replace(/\.d\.ts$/i, ".d.cts");
      fs.writeFileSync(
        dMts,
        this.rewriteRelativeDeclarationSpecifiers(content, ".d.mts", dtsFile),
        "utf8"
      );
      fs.writeFileSync(
        dCts,
        this.rewriteRelativeDeclarationSpecifiers(content, ".d.cts", dtsFile),
        "utf8"
      );
    }

    log.verbose(`Generated ${typeFiles.length * 2} dual declaration files.`);
  }

  private removeLegacyDeclarationFiles() {
    const log = this.log.for(this.removeLegacyDeclarationFiles);
    const typesRoot = path.resolve("lib/types");
    if (!fs.existsSync(typesRoot)) return;

    const legacyFiles = getAllFiles(
      typesRoot,
      (file) => file.endsWith(".d.ts") || file.endsWith(".d.ts.map")
    );
    for (const legacyFile of legacyFiles) {
      try {
        fs.unlinkSync(legacyFile);
      } catch {
        // ignore stale or already-removed files
      }
    }

    log.verbose(`Removed ${legacyFiles.length} legacy declaration files.`);
  }

  private updatePackageJsonDualTypeExports() {
    const log = this.log.for(this.updatePackageJsonDualTypeExports);
    const packageJsonPath = path.resolve("package.json");
    if (!fs.existsSync(packageJsonPath)) return;

    const pkg = JSON.parse(fs.readFileSync(packageJsonPath, "utf8"));
    const exportsField = pkg?.exports;
    if (!exportsField || typeof exportsField !== "object") return;

    const toDualTypePath = (typesPath: string, ext: ".d.mts" | ".d.cts") =>
      typesPath.replace(/\.d\.(ts|mts|cts)$/i, ext);
    const esmToCjsRuntimePath = (runtimePath?: string) => {
      if (!runtimePath) return undefined;
      if (runtimePath.includes("/lib/esm/")) {
        return runtimePath
          .replace("/lib/esm/", "/lib/cjs/")
          .replace(/\.js$/i, ".cjs");
      }
      return runtimePath;
    };
    const esmToTypesPath = (runtimePath?: string, ext: ".d.mts" | ".d.cts" = ".d.mts") => {
      if (!runtimePath) return undefined;
      if (runtimePath.includes("/lib/esm/")) {
        return runtimePath
          .replace("/lib/esm/", "/lib/types/")
          .replace(/\.js$/i, ext);
      }
      return undefined;
    };
    const getDefaultEntry = (value: unknown) => {
      if (typeof value === "string") return value;
      if (
        value &&
        typeof value === "object" &&
        typeof (value as Record<string, unknown>).default === "string"
      ) {
        return (value as Record<string, string>).default;
      }
      return undefined;
    };
    const getTypesEntry = (value: unknown) => {
      if (
        value &&
        typeof value === "object" &&
        typeof (value as Record<string, unknown>).types === "string"
      ) {
        return (value as Record<string, string>).types;
      }
      return undefined;
    };

    const updatedExports: Record<string, any> = {};
    for (const [subpath, target] of Object.entries(exportsField)) {
      if (!target || typeof target !== "object" || Array.isArray(target)) {
        updatedExports[subpath] = target;
        continue;
      }

      const targetObj = target as Record<string, any>;
      const importEntry = getDefaultEntry(targetObj.import);
      const requireEntryRaw = getDefaultEntry(targetObj.require);
      const requireEntry =
        requireEntryRaw && requireEntryRaw.includes("/lib/esm/")
          ? esmToCjsRuntimePath(requireEntryRaw)
          : requireEntryRaw || esmToCjsRuntimePath(importEntry);
      const defaultEntry = getDefaultEntry(targetObj.default);
      const rootTypes =
        (typeof targetObj.types === "string" ? targetObj.types : undefined) ||
        getTypesEntry(targetObj.import);
      const esmTypes =
        rootTypes && /\.d\.(ts|mts|cts)$/i.test(rootTypes)
          ? toDualTypePath(rootTypes, ".d.mts")
          : getTypesEntry(targetObj.import) || esmToTypesPath(importEntry, ".d.mts");
      const cjsTypes =
        rootTypes && /\.d\.(ts|mts|cts)$/i.test(rootTypes)
          ? toDualTypePath(rootTypes, ".d.cts")
          : getTypesEntry(targetObj.require) || esmToTypesPath(importEntry, ".d.cts");

      updatedExports[subpath] = {
        ...(importEntry
          ? {
              import: {
                ...(esmTypes ? { types: esmTypes } : {}),
                default: importEntry,
              },
            }
          : {}),
        ...(requireEntry
          ? {
              require: {
                ...(cjsTypes ? { types: cjsTypes } : {}),
                default: requireEntry,
              },
            }
          : {}),
        ...(defaultEntry || importEntry
          ? { default: defaultEntry || importEntry }
          : {}),
      };
    }

    pkg.exports = updatedExports;
    if (typeof pkg.types === "string" && /\.d\.(ts|mts|cts)$/i.test(pkg.types)) {
      pkg.types = pkg.types.replace(/\.d\.(ts|mts|cts)$/i, ".d.mts");
    }

    fs.writeFileSync(packageJsonPath, JSON.stringify(pkg, null, 2) + "\n", "utf8");
    log.verbose("Updated package.json exports with import/require type conditions.");
  }

  /**
   * @description Copies assets to the build output directory.
   * @summary This method checks for the existence of an 'assets' directory in the source
   * and copies it to the appropriate build output directory (lib or dist).
   * @param {Modes} mode - The build mode (CJS or ESM).
   */
  copyAssets(mode: Modes) {
    const log = this.log.for(this.copyAssets);
    let hasAssets = false;
    try {
      hasAssets = fs.statSync("./src/assets").isDirectory();
      // eslint-disable-next-line @typescript-eslint/no-unused-vars
    } catch (e: unknown) {
      return log.verbose(`No assets found in ./src/assets to copy`);
    }
    if (hasAssets)
      copyFile(
        "./src/assets",
        `./${mode === Modes.CJS ? "lib" : "dist"}/assets`
      );
  }

  /**
   * @description Bundles the project using Rollup.
   * @summary This method configures and runs Rollup to bundle the project. It handles
   * different module formats, development and production builds, and external dependencies.
   * @param {Modes} mode - The module format (CJS or ESM).
   * @param {boolean} isDev - Whether it's a development build.
   * @param {boolean} isLib - Whether it's a library build.
   * @param {string} [entryFile="src/index.ts"] - The entry file for the bundle.
   * @param {string} [nameOverride=this.pkgName] - The name of the output bundle.
   * @param {string|string[]} [externalsArg] - A list of external dependencies.
   * @param {string|string[]} [includeArg] - A list of dependencies to include.
   * @returns {Promise<void>}
   */
  async bundle(
    mode: Modes,
    isDev: boolean,
    isLib: boolean,
    entryFile: string = "./src/index.ts",
    nameOverride: string = this.pkgName,
    externalsArg?: string | string[],
    includeArg: string | string[] = [
      "prompts",
      "styled-string-builder",
      "typed-object-accumulator",
      "@decaf-ts/logging",
    ]
  ) {
    // Run a TypeScript-only diagnostic check for the bundling configuration and fail fast on any errors.
    await this.checkTsDiagnostics(isDev, mode, true);
    const isEsm = mode === Modes.ESM;
    const pkgName = this.pkgName;
    const log = this.log;

    // normalize include and externals
    const include = Array.from(
      new Set([...(parseList(includeArg) as string[])])
    );
    let externalsList = parseList(externalsArg);
    if (externalsList.length === 0) {
      // if no externals specified, list top-level packages in node_modules (expand scopes)
      try {
        externalsList = listNodeModulesPackages(
          path.join(process.cwd(), "node_modules")
        );
      } catch {
        // fallback to package.json dependencies if listing fails or yields nothing
      }
      if (!externalsList || externalsList.length === 0) {
        externalsList = getPackageDependencies();
      }
    }

    const ext = Array.from(
      new Set([
        // builtins and always external runtime deps
        ...(function builtinList(): string[] {
          try {
            return (
              Array.isArray(builtinModules) ? builtinModules : []
            ) as string[];
          } catch {
            // fallback to a reasonable subset if `builtinModules` is unavailable
            return [
              "fs",
              "path",
              "process",
              "child_process",
              "util",
              "https",
              "http",
              "os",
              "stream",
              "crypto",
              "zlib",
              "net",
              "tls",
              "url",
              "querystring",
              "assert",
              "events",
              "tty",
              "dns",
              "querystring",
            ];
          }
        })(),
        ...externalsList,
      ])
    );

    // For plugin-typescript we want it to emit source maps (not inline) so Rollup can
    // decide whether to inline or emit external files. The Rollup output.sourcemap
    // controls final map placement. Do NOT set a non-standard `sourcemap` field on
    // the rollup input options (Rollup will reject it).
    const rollupSourceMapOutput: false | true | "inline" | "hidden" = isDev
      ? "inline"
      : true;

    const plugins = [
      typescript({
        compilerOptions: {
          module: "esnext",
          declaration: false,
          outDir: isLib ? "bin" : "dist",
          // For dev bundles emit inline source maps (no separate .map files).
          // For prod bundles emit external maps so Rollup can write them to disk.
          sourceMap: isDev ? false : true,
          inlineSourceMap: isDev ? true : false,
          inlineSources: isDev ? true : false,
        },
        include: ["src/**/*.ts"],
        exclude: ["node_modules", "**/*.spec.ts"],
        tsconfig: "./tsconfig.json",
      }),
      json(),
    ];

    if (isLib) {
      plugins.push(
        commonjs({
          include: [],
          exclude: externalsList,
        }),
        nodeResolve({
          resolveOnly: include,
        })
      );
    }

    // production minification: add terser last so it sees prior source maps
    try {
      const terserMod: any = await import("@rollup/plugin-terser");
      const terserFn =
        (terserMod && terserMod.terser) || terserMod.default || terserMod;

      const terserOptionsDev: any = {
        parse: { ecma: 2020 },
        compress: false,
        mangle: false,
        format: {
          comments: false,
          beautify: true,
        },
      };

      const terserOptionsProd: any = {
        parse: { ecma: 2020 },
        compress: {
          ecma: 2020,
          passes: 5,
          drop_console: true,
          drop_debugger: true,
          toplevel: true,
          module: isEsm,
          unsafe: true,
          unsafe_arrows: true,
          unsafe_comps: true,
          collapse_vars: true,
          reduce_funcs: true,
          reduce_vars: true,
        },
        mangle: {
          toplevel: true,
        },
        format: {
          comments: false,
          ascii_only: true,
        },
        toplevel: true,
      };

      plugins.push(terserFn(isDev ? terserOptionsDev : terserOptionsProd));
    } catch {
      // if terser isn't available, ignore
    }

    const input: InputOptions = {
      input: entryFile,
      plugins: plugins,
      external: ext,
      onwarn: undefined,
      // enable tree-shaking for production bundles
      treeshake: !isDev,
    } as any;

    // prepare output globals mapping for externals
    const globals: Record<string, string> = {};
    // include all externals and builtins (ext) so Rollup won't guess names for builtins
    ext.forEach((e) => {
      globals[e] = packageToGlobal(e);
    });

    const outputDir = isLib ? "bin" : "dist";
    const entryFileName = `${nameOverride ? nameOverride : `.bundle.${!isDev ? "min" : ""}`}${isEsm ? ".js" : ".cjs"}`;
    const outputs: OutputOptions[] = [
      {
        dir: outputDir,
        entryFileNames: entryFileName,
        format: isLib ? "cjs" : isEsm ? "esm" : "umd",
        name: pkgName,
        esModule: isEsm,
        // output sourcemap: inline for dev, external for prod
        sourcemap: rollupSourceMapOutput,
        globals: globals,
        exports: "auto",
      },
    ];

    try {
      const { rollup } = (await import("rollup")) as typeof import("rollup");
      const bundle = await rollup(input as any);
      // only log watchFiles at verbose level to avoid noisy console output
      log.verbose(bundle.watchFiles);
      async function generateOutputs(bundle: RollupBuild) {
        for (const outputOptions of outputs) {
          await bundle.write(outputOptions);
        }
      }

      try {
        await generateOutputs(bundle);
      } finally {
        await bundle.close();
      }
    } catch (e: unknown) {
      throw new Error(`Failed to bundle: ${e}`);
    }
  }

  private async buildByEnv(
    entryFile: string = "./src/index.ts",
    isDev: boolean,
    mode: BuildMode = BuildMode.ALL,
    validateNodeNext = false,
    includesArg?: string | string[],
    externalsArg?: string | string[]
  ) {
    if (validateNodeNext) await this.checkNodeNextCompatibility();
    // note: includes and externals will be passed through from run() into this method by callers
    try {
      deletePath("lib");
      // eslint-disable-next-line @typescript-eslint/no-unused-vars
    } catch (e: unknown) {
      // do nothing
    }
    try {
      deletePath("dist");
      // eslint-disable-next-line @typescript-eslint/no-unused-vars
    } catch (e: unknown) {
      // do nothing
    }

    if ([BuildMode.ALL, BuildMode.BUILD].includes(mode)) {
      fs.mkdirSync("lib", { recursive: true });
      await this.build(isDev, Modes.ESM);
      await this.build(isDev, Modes.CJS);
      await this.buildTypes(isDev);
      this.patchFiles("lib");
    }

    if ([BuildMode.ALL, BuildMode.BUNDLE].includes(mode)) {
      fs.mkdirSync("dist");
      await this.bundle(
        Modes.ESM,
        isDev,
        false,
        entryFile || "./src/index.ts",
        this.pkgName,
        externalsArg,
        includesArg
      );
      await this.bundle(
        Modes.CJS,
        isDev,
        false,
        entryFile || "./src/index.ts",
        this.pkgName,
        externalsArg,
        includesArg
      );
      this.patchFiles("dist");
    }

    this.copyAssets(Modes.CJS);
    this.copyAssets(Modes.ESM);
  }

  /**
   * @description Builds the project for development.
   * @summary This method runs the build process with development-specific configurations.
   * @param {BuildMode} [mode=BuildMode.ALL] - The build mode (build, bundle, or all).
   * @param {string|string[]} [includesArg] - A list of dependencies to include.
   * @param {string|string[]} [externalsArg] - A list of external dependencies.
   * @returns {Promise<void>}
   */
  async buildDev(
    entryFile: string = "./src/index.ts",
    mode: BuildMode = BuildMode.ALL,
    validateNodeNext = false,
    includesArg?: string | string[],
    externalsArg?: string | string[]
  ) {
    return this.buildByEnv(
      entryFile,
      true,
      mode,
      validateNodeNext,
      includesArg,
      externalsArg
    );
  }

  /**
   * @description Builds the project for production.
   * @summary This method runs the build process with production-specific configurations,
   * including minification and other optimizations.
   * @param {BuildMode} [mode=BuildMode.ALL] - The build mode (build, bundle, or all).
   * @param {string|string[]} [includesArg] - A list of dependencies to include.
   * @param {string|string[]} [externalsArg] - A list of external dependencies.
   * @returns {Promise<void>}
   */
  async buildProd(
    entryFile: string = "./src/index.ts",
    mode: BuildMode = BuildMode.ALL,
    validateNodeNext = false,
    includesArg?: string | string[],
    externalsArg?: string | string[]
  ) {
    return this.buildByEnv(
      entryFile,
      false,
      mode,
      validateNodeNext,
      includesArg,
      externalsArg
    );
  }

  /**
   * @description Generates the project documentation.
   * @summary This method uses JSDoc and other tools to generate HTML documentation for the project.
   * It also patches the README.md file with version and package size information.
   * @returns {Promise<void>}
   */
  async buildDocs() {
    await runCommand(`npm install better-docs taffydb`).promise;
    await runCommand(`npx markdown-include ./workdocs/readme-md.json`).promise;
    await runCommand(
      `npx jsdoc -c ./workdocs/jsdocs.json -t ./node_modules/better-docs`
    ).promise;
    await runCommand(`npm remove better-docs taffydb`).promise;
    [
      {
        src: "workdocs/assets",
        dest: "./docs/workdocs/assets",
      },
      {
        src: "workdocs/reports/coverage",
        dest: "./docs/workdocs/reports/coverage",
      },
      {
        src: "workdocs/reports/html",
        dest: "./docs/workdocs/reports/html",
      },
      {
        src: "workdocs/resources",
        dest: "./docs/workdocs/resources",
      },
      {
        src: "LICENSE.md",
        dest: "./docs/LICENSE.md",
      },
    ].forEach((f) => {
      const { src, dest } = f;
      copyFile(src, dest);
    });

    // patch ./README.md file to replace version/package/package size strings
    try {
      const sizeKb = await getFileSizeZipped(
        path.resolve(path.join(process.cwd(), "dist"))
      );
      this.replacements[PACKAGE_SIZE_STRING] = `${sizeKb} KB`;
    } catch {
      // if we couldn't compute size, leave placeholder or set to unknown
      this.replacements[PACKAGE_SIZE_STRING] = "unknown";
    }

    // Patch README.md in project root
    try {
      patchFile("./README.md", this.replacements);
    } catch (e: unknown) {
      const log = this.log.for(this.buildDocs as any);
      log.verbose(`Failed to patch README.md: ${e}`);
    }
  }

  protected async run<R>(
    answers: LoggingConfig &
      typeof DefaultCommandValues & { [k in keyof typeof options]: unknown }
  ): Promise<string | void | R> {
    const {
      dev,
      prod,
      docs,
      buildMode,
      includes,
      externals,
      entry,
      validateNodeNext,
    } = answers as any;
    if (dev) {
      return await this.buildDev(
        entry || "./src/index.ts",
        buildMode as BuildMode,
        !!validateNodeNext,
        includes,
        externals
      );
    }
    if (prod) {
      return await this.buildProd(
        entry || "./src/index.ts",
        buildMode as BuildMode,
        !!validateNodeNext,
        includes,
        externals
      );
    }
    if (docs) {
      return await this.buildDocs();
    }
  }
}