From fc44e3a4e83fe3275696c5b36fb597145e479e6f Mon Sep 17 00:00:00 2001
From: Matt Lewis <matt@clickup.com>
Date: Tue, 26 Nov 2024 14:18:35 +0000
Subject: [PATCH] feat(custom-esbuild): expose current builder options and
 target to plugins

---
 packages/custom-esbuild/README.md             | 27 ++++++++--
 .../custom-esbuild/src/application/index.ts   |  9 +++-
 .../custom-esbuild/src/dev-server/index.ts    |  4 +-
 .../custom-esbuild/src/load-plugin.spec.ts    | 49 ++++++++++++++++---
 packages/custom-esbuild/src/load-plugins.ts   | 37 +++++++++++---
 5 files changed, 106 insertions(+), 20 deletions(-)

diff --git a/packages/custom-esbuild/README.md b/packages/custom-esbuild/README.md
index 4055b7833..f6497554c 100644
--- a/packages/custom-esbuild/README.md
+++ b/packages/custom-esbuild/README.md
@@ -118,7 +118,7 @@ Builder options:
 
 In the above example, we specify the list of `plugins` that should implement the ESBuild plugin schema. These plugins are custom user plugins and are added to the original ESBuild Angular configuration. Additionally, the `indexHtmlTransformer` property is used to specify the path to the file that exports the function used to modify the `index.html`.
 
-The plugin file can export either a single plugin or a list of plugins. If a plugin accepts configuration then the config should be provided in `angular.json`:
+The plugin file can export either a single plugin, a list of plugins or a factory function that returns a plugin or list of plugins. If a plugin accepts configuration then the config should be provided in `angular.json`:
 
 ```ts
 // esbuild/plugins.ts
@@ -143,13 +143,13 @@ import type { Plugin, PluginBuild } from 'esbuild';
 
 function defineEnv(pluginOptions: { stage: string }): Plugin {
   return {
-    name: 'define-env',  
+    name: 'define-env',
     setup(build: PluginBuild) {
       const buildOptions = build.initialOptions;
       buildOptions.define.stage = JSON.stringify(pluginOptions.stage);
     },
   };
-};
+}
 
 export default defineEnv;
 ```
@@ -180,6 +180,25 @@ const updateExternalPlugin: Plugin = {
 export default [defineTextPlugin, updateExternalPlugin];
 ```
 
+Or:
+
+```ts
+// esbuild/plugins.ts
+import type { Plugin, PluginBuild } from 'esbuild';
+import type { ApplicationBuilderOptions } from '@angular-devkit/build-angular';
+import type { Target } from '@angular-devkit/architect';
+
+export default (builderOptions: ApplicationBuilderOptions, target: Target): Plugin => {
+  return {
+    name: 'define-text',
+    setup(build: PluginBuild) {
+      const options = build.initialOptions;
+      options.define.currentProject = JSON.stringify(target.project);
+    },
+  };
+};
+```
+
 ## Custom ESBuild `dev-server`
 
 The `@angular-builders/custom-esbuild:dev-server` is an enhanced version of the `@angular-devkit/build-angular:dev-server` builder that allows the specification of `middlewares` (Vite's `Connect` functions). It also obtains `plugins` and `indexHtmlTransformer` from the `:application` configuration to run the Vite server with all the necessary configuration applied.
@@ -239,7 +258,7 @@ It is useful when you want to transform your `index.html` according to the build
 `index-html-transformer.js`:
 
 ```js
-module.exports = (indexHtml) => {
+module.exports = indexHtml => {
   const i = indexHtml.indexOf('</body>');
   const content = `<p>Dynamically inserted content</p>`;
   return `${indexHtml.slice(0, i)}
diff --git a/packages/custom-esbuild/src/application/index.ts b/packages/custom-esbuild/src/application/index.ts
index faf9f1513..76722f1a8 100644
--- a/packages/custom-esbuild/src/application/index.ts
+++ b/packages/custom-esbuild/src/application/index.ts
@@ -17,7 +17,14 @@ export function buildCustomEsbuildApplication(
   const tsConfig = path.join(workspaceRoot, options.tsConfig);
 
   return defer(async () => {
-    const codePlugins = await loadPlugins(options.plugins, workspaceRoot, tsConfig, context.logger);
+    const codePlugins = await loadPlugins(
+      options.plugins,
+      workspaceRoot,
+      tsConfig,
+      context.logger,
+      options,
+      context.target
+    );
 
     const indexHtmlTransformer = options.indexHtmlTransformer
       ? await loadModule(
diff --git a/packages/custom-esbuild/src/dev-server/index.ts b/packages/custom-esbuild/src/dev-server/index.ts
index 3ab9eab88..f15f01a53 100644
--- a/packages/custom-esbuild/src/dev-server/index.ts
+++ b/packages/custom-esbuild/src/dev-server/index.ts
@@ -54,7 +54,9 @@ export function executeCustomDevServerBuilder(
         buildOptions.plugins,
         workspaceRoot,
         tsConfig,
-        context.logger
+        context.logger,
+        options,
+        context.target
       );
 
       const indexHtmlTransformer: IndexHtmlTransform = buildOptions.indexHtmlTransformer
diff --git a/packages/custom-esbuild/src/load-plugin.spec.ts b/packages/custom-esbuild/src/load-plugin.spec.ts
index 2a6d08cb2..ecb367b42 100644
--- a/packages/custom-esbuild/src/load-plugin.spec.ts
+++ b/packages/custom-esbuild/src/load-plugin.spec.ts
@@ -1,4 +1,7 @@
 import { loadPlugins } from './load-plugins';
+import { Target } from '@angular-devkit/architect';
+import { Plugin } from 'esbuild';
+import { CustomEsbuildApplicationSchema } from './custom-esbuild-schema';
 
 describe('loadPlugin', () => {
   beforeEach(() => {
@@ -7,20 +10,54 @@ describe('loadPlugin', () => {
   });
 
   it('should load a plugin without configuration', async () => {
-    const pluginFactory = jest.fn();
+    const mockPlugin = { name: 'mock' } as Plugin;
+    jest.mock('test/test-plugin.js', () => mockPlugin, { virtual: true });
+    const plugin = await loadPlugins(
+      ['test-plugin.js'],
+      './test',
+      './tsconfig.json',
+      null as any,
+      {} as any,
+      {} as any
+    );
+
+    expect(plugin).toEqual([mockPlugin]);
+  });
+
+  it('should load a plugin factory without configuration and pass options and target', async () => {
+    const mockPlugin = { name: 'mock' } as Plugin;
+    const pluginFactory = jest.fn().mockReturnValue(mockPlugin);
+    const mockOptions = { tsConfig: './tsconfig.json' } as CustomEsbuildApplicationSchema;
+    const mockTarget = { target: 'test' } as Target;
     jest.mock('test/test-plugin.js', () => pluginFactory, { virtual: true });
-    const plugin = await loadPlugins(['test-plugin.js'], './test', './tsconfig.json', null as any);
+    const plugin = await loadPlugins(
+      ['test-plugin.js'],
+      './test',
+      './tsconfig.json',
+      null as any,
+      mockOptions,
+      mockTarget
+    );
 
-    expect(pluginFactory).not.toHaveBeenCalled();
-    expect(plugin).toBeDefined();
+    expect(pluginFactory).toHaveBeenCalledWith(mockOptions, mockTarget);
+    expect(plugin).toEqual([mockPlugin]);
   });
 
   it('should load a plugin with configuration', async () => {
     const pluginFactory = jest.fn();
+    const mockOptions = { tsConfig: './tsconfig.json' } as CustomEsbuildApplicationSchema;
+    const mockTarget = { target: 'test' } as Target;
     jest.mock('test/test-plugin.js', () => pluginFactory, { virtual: true });
-    const plugin = await loadPlugins([{ path: 'test-plugin.js', options: { test: 'test' } }], './test', './tsconfig.json', null as any);
+    const plugin = await loadPlugins(
+      [{ path: 'test-plugin.js', options: { test: 'test' } }],
+      './test',
+      './tsconfig.json',
+      null as any,
+      mockOptions,
+      mockTarget
+    );
 
-    expect(pluginFactory).toHaveBeenCalledWith({ test: 'test' });
+    expect(pluginFactory).toHaveBeenCalledWith({ test: 'test' }, mockOptions, mockTarget);
     expect(plugin).toBeDefined();
   });
 });
diff --git a/packages/custom-esbuild/src/load-plugins.ts b/packages/custom-esbuild/src/load-plugins.ts
index 8f4f51aa9..ed73fbee9 100644
--- a/packages/custom-esbuild/src/load-plugins.ts
+++ b/packages/custom-esbuild/src/load-plugins.ts
@@ -2,25 +2,46 @@ import * as path from 'node:path';
 import type { Plugin } from 'esbuild';
 import type { logging } from '@angular-devkit/core';
 import { loadModule } from '@angular-builders/common';
-import { PluginConfig } from './custom-esbuild-schema';
+import {
+  CustomEsbuildApplicationSchema,
+  CustomEsbuildDevServerSchema,
+  PluginConfig,
+} from './custom-esbuild-schema';
+import { Target } from '@angular-devkit/architect';
 
 export async function loadPlugins(
   pluginConfig: PluginConfig[] | undefined,
   workspaceRoot: string,
   tsConfig: string,
   logger: logging.LoggerApi,
+  builderOptions: CustomEsbuildApplicationSchema | CustomEsbuildDevServerSchema,
+  target: Target
 ): Promise<Plugin[]> {
   const plugins = await Promise.all(
     (pluginConfig || []).map(async pluginConfig => {
-        if (typeof pluginConfig === 'string') {
-          return loadModule<Plugin | Plugin[]>(path.join(workspaceRoot, pluginConfig), tsConfig, logger);
+      if (typeof pluginConfig === 'string') {
+        const pluginsOrFactory = await loadModule<
+          | Plugin
+          | Plugin[]
+          | ((
+              options: CustomEsbuildApplicationSchema | CustomEsbuildDevServerSchema,
+              target: Target
+            ) => Plugin | Plugin[])
+        >(path.join(workspaceRoot, pluginConfig), tsConfig, logger);
+        if (typeof pluginsOrFactory === 'function') {
+          return pluginsOrFactory(builderOptions, target);
         } else {
-          const pluginFactory = await loadModule<(...args: any[]) => Plugin>(path.join(workspaceRoot, pluginConfig.path), tsConfig, logger);
-          return pluginFactory(pluginConfig.options);
+          return pluginsOrFactory;
         }
-
-      },
-    ),
+      } else {
+        const pluginFactory = await loadModule<(...args: any[]) => Plugin>(
+          path.join(workspaceRoot, pluginConfig.path),
+          tsConfig,
+          logger
+        );
+        return pluginFactory(pluginConfig.options, builderOptions, target);
+      }
+    })
   );
 
   return plugins.flat();