From 81f145d5c274dc54eed6120e8893cdedcd26558d Mon Sep 17 00:00:00 2001 From: Maximilian Koeller Date: Fri, 16 Feb 2024 17:43:13 +0100 Subject: [PATCH] feat(custom-esbuild): add support for plugin configuration --- .../sanity-esbuild-app-esm/angular.json | 40 +++++++++++++++++-- .../esbuild/define-text-by-option-plugin.cjs | 11 +++++ .../esbuild/define-text-by-option-plugin.js | 11 +++++ .../esbuild/define-text-by-option-plugin.ts | 13 ++++++ .../src/app/app.component.html | 1 + .../src/app/app.component.ts | 3 ++ .../sanity-esbuild-app/angular.json | 30 ++++++++++++-- .../esbuild/define-text-by-option-plugin.js | 11 +++++ .../esbuild/define-text-by-option-plugin.mjs | 11 +++++ .../src/app/app.component.html | 1 + .../src/app/app.component.ts | 3 ++ packages/custom-esbuild/README.md | 29 +++++++++++++- packages/custom-esbuild/package.json | 3 +- .../src/application/schema.ext.json | 20 +++++++++- .../src/custom-esbuild-schema.ts | 2 + .../custom-esbuild/src/load-plugin.spec.ts | 26 ++++++++++++ packages/custom-esbuild/src/load-plugins.ts | 18 ++++++--- 17 files changed, 217 insertions(+), 16 deletions(-) create mode 100644 examples/custom-esbuild/sanity-esbuild-app-esm/esbuild/define-text-by-option-plugin.cjs create mode 100644 examples/custom-esbuild/sanity-esbuild-app-esm/esbuild/define-text-by-option-plugin.js create mode 100644 examples/custom-esbuild/sanity-esbuild-app-esm/esbuild/define-text-by-option-plugin.ts create mode 100644 examples/custom-esbuild/sanity-esbuild-app/esbuild/define-text-by-option-plugin.js create mode 100644 examples/custom-esbuild/sanity-esbuild-app/esbuild/define-text-by-option-plugin.mjs create mode 100644 packages/custom-esbuild/src/load-plugin.spec.ts diff --git a/examples/custom-esbuild/sanity-esbuild-app-esm/angular.json b/examples/custom-esbuild/sanity-esbuild-app-esm/angular.json index 7ed10f251..9c3dd6fff 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 000000000..c3d072e5f --- /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 = JSON.stringify(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 000000000..7c6cf02b6 --- /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 = JSON.stringify(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 000000000..2e32da11d --- /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'] = JSON.stringify(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 d7f6fe579..7034c8f30 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 13530b452..d15987ef2 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 9d5f0c0b2..85f28e0f7 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 000000000..c3d072e5f --- /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 = JSON.stringify(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 000000000..7c6cf02b6 --- /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 = JSON.stringify(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 d7f6fe579..7034c8f30 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 f8ae812e3..b87846734 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 878e2570d..7469b8a62 100644 --- a/packages/custom-esbuild/README.md +++ b/packages/custom-esbuild/README.md @@ -100,19 +100,25 @@ Builder options: "build": { "builder": "@angular-builders/custom-esbuild:application", "options": { - "plugins": ["./esbuild/plugins.ts", "./esbuild/plugin-2.js"], + "plugins": ["./esbuild/plugins.ts", { "path": "./esbuild/define-env.ts", "options": { "stage": "development" } }], "indexHtmlTransformer": "./esbuild/index-html-transformer.js", "outputPath": "dist/my-cool-client", "index": "src/index.html", "browser": "src/main.ts", "polyfills": ["zone.js"], "tsConfig": "src/tsconfig.app.json" + }, + "configurations": { + "production": { + "plugins": ["./esbuild/plugins.ts", { "path": "./esbuild/define-env.ts", "options": { "stage": "production" } }] + } } + } ``` 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 +135,25 @@ const defineTextPlugin: Plugin = { export default defineTextPlugin; ``` +OR: + +```ts +// esbuild/plugins.ts +import type { Plugin, PluginBuild } from 'esbuild'; + +function defineEnv(pluginOptions: { stage: string }): Plugin { + return { + name: 'define-env', + setup(build: PluginBuild) { + const buildOptions = build.initialOptions; + buildOptions.define.stage = JSON.stringify(pluginOptions.stage); + }, + }; +}; + +export default defineEnv; +``` + Or: ```ts diff --git a/packages/custom-esbuild/package.json b/packages/custom-esbuild/package.json index 7936b00fd..ca3ed892b 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 b1450f68a..4d06b24ab 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 eac9dc1e8..59c07d6e3 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 000000000..2a6d08cb2 --- /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 f0e154943..8f4f51aa9 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();