diff --git a/examples/custom-esbuild/sanity-esbuild-app-esm/angular.json b/examples/custom-esbuild/sanity-esbuild-app-esm/angular.json index 7ed10f251f..9c3dd6fffa 100644 --- a/examples/custom-esbuild/sanity-esbuild-app-esm/angular.json +++ b/examples/custom-esbuild/sanity-esbuild-app-esm/angular.json @@ -17,7 +17,15 @@ "build": { "builder": "@angular-builders/custom-esbuild:application", "options": { - "plugins": ["esbuild/define-text-plugin.js"], + "plugins": [ + "esbuild/define-text-plugin.js", + { + "path": "esbuild/define-text-by-option-plugin.js", + "options": { + "title": "sanity-esbuild-app-esm optionTitle (compilation provided)" + } + } + ], "outputPath": "dist/sanity-esbuild-app-esm", "index": "src/index.html", "browser": "src/main.ts", @@ -45,13 +53,37 @@ "outputHashing": "all" }, "esm": { - "plugins": ["esbuild/define-text-plugin.js"] + "plugins": [ + "esbuild/define-text-plugin.js", + { + "path": "esbuild/define-text-by-option-plugin.js", + "options": { + "title": "sanity-esbuild-app-esm optionTitle (compilation provided)" + } + } + ] }, "cjs": { - "plugins": ["esbuild/define-text-plugin.cjs"] + "plugins": [ + "esbuild/define-text-plugin.cjs", + { + "path": "esbuild/define-text-by-option-plugin.cjs", + "options": { + "title": "sanity-esbuild-app-esm optionTitle (compilation provided)" + } + } + ] }, "tsEsm": { - "plugins": ["esbuild/define-text-plugin.ts"] + "plugins": [ + "esbuild/define-text-plugin.ts", + { + "path": "esbuild/define-text-by-option-plugin.ts", + "options": { + "title": "sanity-esbuild-app-esm optionTitle (compilation provided)" + } + } + ] } }, "defaultConfiguration": "production" diff --git a/examples/custom-esbuild/sanity-esbuild-app-esm/esbuild/define-text-by-option-plugin.cjs b/examples/custom-esbuild/sanity-esbuild-app-esm/esbuild/define-text-by-option-plugin.cjs new file mode 100644 index 0000000000..b60b3545ca --- /dev/null +++ b/examples/custom-esbuild/sanity-esbuild-app-esm/esbuild/define-text-by-option-plugin.cjs @@ -0,0 +1,11 @@ +function defineTitleByOptionPlugin(pluginOptions) { + return { + name: 'define-title', + setup(build) { + const options = build.initialOptions; + options.define.titleByOption = pluginOptions.title; + }, + }; +}; + +module.exports = defineTitleByOptionPlugin; diff --git a/examples/custom-esbuild/sanity-esbuild-app-esm/esbuild/define-text-by-option-plugin.js b/examples/custom-esbuild/sanity-esbuild-app-esm/esbuild/define-text-by-option-plugin.js new file mode 100644 index 0000000000..09dc89f22b --- /dev/null +++ b/examples/custom-esbuild/sanity-esbuild-app-esm/esbuild/define-text-by-option-plugin.js @@ -0,0 +1,11 @@ +function defineTitleByOptionPlugin(pluginOptions) { + return { + name: 'define-title', + setup(build) { + const options = build.initialOptions; + options.define.titleByOption = pluginOptions.title; + }, + }; +}; + +export default defineTitleByOptionPlugin; diff --git a/examples/custom-esbuild/sanity-esbuild-app-esm/esbuild/define-text-by-option-plugin.ts b/examples/custom-esbuild/sanity-esbuild-app-esm/esbuild/define-text-by-option-plugin.ts new file mode 100644 index 0000000000..f9bb144d8a --- /dev/null +++ b/examples/custom-esbuild/sanity-esbuild-app-esm/esbuild/define-text-by-option-plugin.ts @@ -0,0 +1,13 @@ +import type { Plugin, PluginBuild } from 'esbuild'; + +function defineTitleByOptionPlugin(pluginOptions: {title: string}): Plugin { + return { + name: 'define-title', + setup(build: PluginBuild) { + const options = build.initialOptions; + options.define!['titleByOption'] = pluginOptions.title; + }, + }; +}; + +export default defineTitleByOptionPlugin; diff --git a/examples/custom-esbuild/sanity-esbuild-app-esm/src/app/app.component.html b/examples/custom-esbuild/sanity-esbuild-app-esm/src/app/app.component.html index d7f6fe5790..7034c8f302 100644 --- a/examples/custom-esbuild/sanity-esbuild-app-esm/src/app/app.component.html +++ b/examples/custom-esbuild/sanity-esbuild-app-esm/src/app/app.component.html @@ -1,2 +1,3 @@

{{ title }}

{{ subtitle }}

+

{{ titleByOption }}

diff --git a/examples/custom-esbuild/sanity-esbuild-app-esm/src/app/app.component.ts b/examples/custom-esbuild/sanity-esbuild-app-esm/src/app/app.component.ts index 13530b4526..d15987ef2e 100644 --- a/examples/custom-esbuild/sanity-esbuild-app-esm/src/app/app.component.ts +++ b/examples/custom-esbuild/sanity-esbuild-app-esm/src/app/app.component.ts @@ -2,6 +2,7 @@ import { Component } from '@angular/core'; declare const title: string; declare const subtitle: string; +declare const titleByOption: string; @Component({ selector: 'app-root', @@ -12,9 +13,11 @@ declare const subtitle: string; export class AppComponent { title: string; subtitle: string; + titleByOption: string; constructor() { this.title = typeof title !== 'undefined' ? title : 'sanity-esbuild-app-esm'; this.subtitle = typeof subtitle !== 'undefined' ? subtitle : 'sanity-esbuild-app-esm subtitle'; + this.titleByOption = typeof titleByOption !== 'undefined' ? titleByOption : 'sanity-esbuild-app-esm optionTitle'; } } diff --git a/examples/custom-esbuild/sanity-esbuild-app/angular.json b/examples/custom-esbuild/sanity-esbuild-app/angular.json index 9d5f0c0b25..85f28e0f7d 100644 --- a/examples/custom-esbuild/sanity-esbuild-app/angular.json +++ b/examples/custom-esbuild/sanity-esbuild-app/angular.json @@ -17,7 +17,15 @@ "build": { "builder": "@angular-builders/custom-esbuild:application", "options": { - "plugins": ["esbuild/define-text-plugin.js"], + "plugins": [ + "esbuild/define-text-plugin.js", + { + "path": "esbuild/define-text-by-option-plugin.js", + "options": { + "title": "sanity-esbuild-app optionTitle (compilation provided)" + } + } + ], "outputPath": "dist/sanity-esbuild-app", "index": "src/index.html", "browser": "src/main.ts", @@ -45,10 +53,26 @@ "outputHashing": "all" }, "esm": { - "plugins": ["esbuild/define-text-plugin.mjs"] + "plugins": [ + "esbuild/define-text-plugin.mjs", + { + "path": "esbuild/define-text-by-option-plugin.mjs", + "options": { + "title": "sanity-esbuild-app optionTitle (compilation provided)" + } + } + ] }, "cjs": { - "plugins": ["esbuild/define-text-plugin.js"] + "plugins": [ + "esbuild/define-text-plugin.js", + { + "path": "esbuild/define-text-by-option-plugin.js", + "options": { + "title": "sanity-esbuild-app optionTitle (compilation provided)" + } + } + ] } }, "defaultConfiguration": "production" diff --git a/examples/custom-esbuild/sanity-esbuild-app/esbuild/define-text-by-option-plugin.js b/examples/custom-esbuild/sanity-esbuild-app/esbuild/define-text-by-option-plugin.js new file mode 100644 index 0000000000..b60b3545ca --- /dev/null +++ b/examples/custom-esbuild/sanity-esbuild-app/esbuild/define-text-by-option-plugin.js @@ -0,0 +1,11 @@ +function defineTitleByOptionPlugin(pluginOptions) { + return { + name: 'define-title', + setup(build) { + const options = build.initialOptions; + options.define.titleByOption = pluginOptions.title; + }, + }; +}; + +module.exports = defineTitleByOptionPlugin; diff --git a/examples/custom-esbuild/sanity-esbuild-app/esbuild/define-text-by-option-plugin.mjs b/examples/custom-esbuild/sanity-esbuild-app/esbuild/define-text-by-option-plugin.mjs new file mode 100644 index 0000000000..09dc89f22b --- /dev/null +++ b/examples/custom-esbuild/sanity-esbuild-app/esbuild/define-text-by-option-plugin.mjs @@ -0,0 +1,11 @@ +function defineTitleByOptionPlugin(pluginOptions) { + return { + name: 'define-title', + setup(build) { + const options = build.initialOptions; + options.define.titleByOption = pluginOptions.title; + }, + }; +}; + +export default defineTitleByOptionPlugin; diff --git a/examples/custom-esbuild/sanity-esbuild-app/src/app/app.component.html b/examples/custom-esbuild/sanity-esbuild-app/src/app/app.component.html index d7f6fe5790..7034c8f302 100644 --- a/examples/custom-esbuild/sanity-esbuild-app/src/app/app.component.html +++ b/examples/custom-esbuild/sanity-esbuild-app/src/app/app.component.html @@ -1,2 +1,3 @@

{{ title }}

{{ subtitle }}

+

{{ titleByOption }}

diff --git a/examples/custom-esbuild/sanity-esbuild-app/src/app/app.component.ts b/examples/custom-esbuild/sanity-esbuild-app/src/app/app.component.ts index f8ae812e3e..b878467344 100644 --- a/examples/custom-esbuild/sanity-esbuild-app/src/app/app.component.ts +++ b/examples/custom-esbuild/sanity-esbuild-app/src/app/app.component.ts @@ -2,6 +2,7 @@ import { Component } from '@angular/core'; declare const title: string; declare const subtitle: string; +declare const titleByOption: string; @Component({ selector: 'app-root', @@ -12,9 +13,11 @@ declare const subtitle: string; export class AppComponent { title: string; subtitle: string; + titleByOption: string; constructor() { this.title = typeof title !== 'undefined' ? title : 'sanity-esbuild-app'; this.subtitle = typeof subtitle !== 'undefined' ? subtitle : 'sanity-esbuild-app subtitle'; + this.titleByOption = typeof titleByOption !== 'undefined' ? titleByOption : 'sanity-esbuild-app optionTitle'; } } diff --git a/packages/custom-esbuild/README.md b/packages/custom-esbuild/README.md index 878e2570da..cf0aabb721 100644 --- a/packages/custom-esbuild/README.md +++ b/packages/custom-esbuild/README.md @@ -100,7 +100,7 @@ Builder options: "build": { "builder": "@angular-builders/custom-esbuild:application", "options": { - "plugins": ["./esbuild/plugins.ts", "./esbuild/plugin-2.js"], + "plugins": ["./esbuild/plugins.ts", { "path": "./esbuild/plugin-2.js", "options": { "key": "value" } }], "indexHtmlTransformer": "./esbuild/index-html-transformer.js", "outputPath": "dist/my-cool-client", "index": "src/index.html", @@ -112,7 +112,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: +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`: ```ts // esbuild/plugins.ts @@ -129,6 +129,25 @@ const defineTextPlugin: Plugin = { export default defineTextPlugin; ``` +OR: + +```ts +// esbuild/plugins.ts +import type { Plugin, PluginBuild } from 'esbuild'; + +function defineRewritePathPlugin(options: { text: string }): Plugin { + return { + name: 'define-text', + setup(build: PluginBuild) { + const options = build.initialOptions; + options.define.buildText = JSON.stringify(options.text); + }, + }; +}; + +export default defineTextPlugin; +``` + Or: ```ts diff --git a/packages/custom-esbuild/package.json b/packages/custom-esbuild/package.json index 7936b00fd1..ca3ed892b9 100644 --- a/packages/custom-esbuild/package.json +++ b/packages/custom-esbuild/package.json @@ -32,7 +32,8 @@ "scripts": { "prebuild": "yarn clean", "build": "yarn prebuild && tsc && ts-node ../../merge-schemes.ts && yarn postbuild", - "postbuild": "yarn run e2e", + "postbuild": "yarn test && yarn run e2e", + "test": "jest --config ../../jest-ut.config.js", "e2e": "jest --config ../../jest-e2e.config.js", "clean": "rimraf dist", "ci": "./scripts/ci.sh" diff --git a/packages/custom-esbuild/src/application/schema.ext.json b/packages/custom-esbuild/src/application/schema.ext.json index b1450f68a4..4d06b24ab4 100644 --- a/packages/custom-esbuild/src/application/schema.ext.json +++ b/packages/custom-esbuild/src/application/schema.ext.json @@ -8,7 +8,25 @@ "description": "A list of paths to ESBuild plugins", "default": [], "items": { - "type": "string", + "anyOf": [ + { + "type": "string" + }, + { + "type": "object", + "properties": { + "path": { + "type": "string" + }, + "options": { + "type": "object" + } + }, + "required": [ + "path" + ] + } + ], "uniqueItems": true } }, diff --git a/packages/custom-esbuild/src/custom-esbuild-schema.ts b/packages/custom-esbuild/src/custom-esbuild-schema.ts index eac9dc1e86..59c07d6e3e 100644 --- a/packages/custom-esbuild/src/custom-esbuild-schema.ts +++ b/packages/custom-esbuild/src/custom-esbuild-schema.ts @@ -1,5 +1,7 @@ import { ApplicationBuilderOptions, DevServerBuilderOptions } from '@angular-devkit/build-angular'; +export type PluginConfig = string | { path: string; options?: Record }; + export type CustomEsbuildApplicationSchema = ApplicationBuilderOptions & { plugins?: string[]; indexHtmlTransformer?: string; diff --git a/packages/custom-esbuild/src/load-plugin.spec.ts b/packages/custom-esbuild/src/load-plugin.spec.ts new file mode 100644 index 0000000000..2a6d08cb20 --- /dev/null +++ b/packages/custom-esbuild/src/load-plugin.spec.ts @@ -0,0 +1,26 @@ +import { loadPlugins } from './load-plugins'; + +describe('loadPlugin', () => { + beforeEach(() => { + jest.resetModules(); + jest.clearAllMocks(); + }); + + it('should load a plugin without configuration', async () => { + const pluginFactory = jest.fn(); + jest.mock('test/test-plugin.js', () => pluginFactory, { virtual: true }); + const plugin = await loadPlugins(['test-plugin.js'], './test', './tsconfig.json', null as any); + + expect(pluginFactory).not.toHaveBeenCalled(); + expect(plugin).toBeDefined(); + }); + + it('should load a plugin with configuration', async () => { + const pluginFactory = jest.fn(); + 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); + + expect(pluginFactory).toHaveBeenCalledWith({ test: 'test' }); + expect(plugin).toBeDefined(); + }); +}); diff --git a/packages/custom-esbuild/src/load-plugins.ts b/packages/custom-esbuild/src/load-plugins.ts index f0e154943b..8f4f51aa9b 100644 --- a/packages/custom-esbuild/src/load-plugins.ts +++ b/packages/custom-esbuild/src/load-plugins.ts @@ -2,17 +2,25 @@ 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'; export async function loadPlugins( - paths: string[] | undefined, + pluginConfig: PluginConfig[] | undefined, workspaceRoot: string, tsConfig: string, - logger: logging.LoggerApi + logger: logging.LoggerApi, ): Promise { const plugins = await Promise.all( - (paths || []).map(pluginPath => - loadModule(path.join(workspaceRoot, pluginPath), tsConfig, logger) - ) + (pluginConfig || []).map(async pluginConfig => { + if (typeof pluginConfig === 'string') { + return loadModule(path.join(workspaceRoot, pluginConfig), tsConfig, logger); + } else { + const pluginFactory = await loadModule<(...args: any[]) => Plugin>(path.join(workspaceRoot, pluginConfig.path), tsConfig, logger); + return pluginFactory(pluginConfig.options); + } + + }, + ), ); return plugins.flat();