From 6c4b5e1e4172f4db1bf7a537c0e221c9786c441d Mon Sep 17 00:00:00 2001 From: Parth Shah Date: Fri, 17 Jul 2026 16:11:26 -0400 Subject: [PATCH] TRAC-1206: feat - support esm in stencil-cli --- lib/BuildConfigManager.js | 42 ++++++++++++---- lib/BuildConfigManager.spec.js | 28 +++++++++++ lib/stencil-bundle.js | 1 + .../build-config/esm-config/stencil.conf.cjs | 9 ++++ .../build-config/esm-config/stencil.conf.mjs | 50 +++++++++++++++++++ 5 files changed, 119 insertions(+), 11 deletions(-) create mode 100644 test/_mocks/build-config/esm-config/stencil.conf.cjs create mode 100644 test/_mocks/build-config/esm-config/stencil.conf.mjs diff --git a/lib/BuildConfigManager.js b/lib/BuildConfigManager.js index 587cac22..0f47e76e 100644 --- a/lib/BuildConfigManager.js +++ b/lib/BuildConfigManager.js @@ -2,10 +2,9 @@ import { once } from 'lodash-es'; import { fork } from 'child_process'; import path from 'path'; import fsModule from 'fs'; -import { createRequire } from 'node:module'; +import { pathToFileURL } from 'node:url'; import { THEME_PATH } from '../constants.js'; -const require = createRequire(import.meta.url); const isJest = typeof process !== 'undefined' && 'JEST_WORKER_ID' in process.env; const cornerstoneConfigLink = isJest @@ -16,8 +15,10 @@ class BuildConfigManager { constructor({ workDir = THEME_PATH, fs = fsModule, timeout = 20000 } = {}) { this.oldConfigFileName = 'stencil.conf.js'; this.configFileName = 'stencil.conf.cjs'; + this.esmConfigFileName = 'stencil.conf.mjs'; this._workDir = workDir; this._buildConfigPath = path.join(workDir, this.configFileName); + this._esmBuildConfigPath = path.join(workDir, this.esmConfigFileName); this._oldBuildConfigPath = path.join(workDir, this.oldConfigFileName); this._fs = fs; this._onReadyCallbacks = []; @@ -27,15 +28,19 @@ class BuildConfigManager { } async initConfig() { - const config = await this._getConfig(this._buildConfigPath, this._oldBuildConfigPath); + const config = await this._getConfig( + this._resolveBuildConfigPath(), + this._oldBuildConfigPath, + ); this.development = config.development || this._devWorker; this.production = config.production || this._prodWorker; this.watchOptions = config.watchOptions; } initWorker() { - if (this._fs.existsSync(this._buildConfigPath)) { - this._worker = fork(this._buildConfigPath, [], { cwd: this._workDir }); + const buildConfigPath = this._resolveBuildConfigPath(); + if (this._fs.existsSync(buildConfigPath)) { + this._worker = fork(buildConfigPath, [], { cwd: this._workDir }); this._worker.on('message', (message) => { if (message === 'ready') { this._workerIsReady = true; @@ -50,23 +55,38 @@ class BuildConfigManager { this._worker.kill(signal); } + // Prefer an ESM stencil.conf.mjs when the theme provides one; otherwise + // use the CommonJS stencil.conf.cjs (which legacy stencil.conf.js files + // get renamed to) + _resolveBuildConfigPath() { + if (this._fs.existsSync(this._esmBuildConfigPath)) { + return this._esmBuildConfigPath; + } + return this._buildConfigPath; + } + async _getConfig(configPath, oldConfigPath) { if (this._fs.existsSync(configPath)) { - // eslint-disable-next-line import/no-dynamic-require - return require(configPath); + return this._importConfig(configPath); } if (this._fs.existsSync(oldConfigPath)) { this._moveOldConfig(configPath, oldConfigPath); - // eslint-disable-next-line import/no-dynamic-require - return require(configPath); + return this._importConfig(configPath); } // Fallback to cornerstone default stencil.conf.cjs const content = await this.downloadFileFromGitHub(cornerstoneConfigLink); this._fs.writeFileSync(configPath, content); - // eslint-disable-next-line import/no-dynamic-require - return require(configPath); + return this._importConfig(configPath); + } + + // Dynamic import() loads both ESM and CommonJS configs. CommonJS exposes + // module.exports as the default export; ESM configs use named exports + async _importConfig(configPath) { + // eslint-disable-next-line node/no-unsupported-features/es-syntax + const configModule = await import(pathToFileURL(configPath).href); + return configModule.default ?? configModule; } async downloadFileFromGitHub(rawUrl) { diff --git a/lib/BuildConfigManager.spec.js b/lib/BuildConfigManager.spec.js index a60c945e..a6bf1a56 100644 --- a/lib/BuildConfigManager.spec.js +++ b/lib/BuildConfigManager.spec.js @@ -18,6 +18,13 @@ describe('BuildConfigManager integration tests', () => { expect(buildConfig.watchOptions.files).toBeInstanceOf(Array); expect(buildConfig.watchOptions.ignored).toBeInstanceOf(Array); }); + it('should prefer stencil.conf.mjs over stencil.conf.cjs when both exist', async () => { + const buildConfig = new BuildConfigManager({ + workDir: `${cwd}/test/_mocks/build-config/esm-config`, + }); + await buildConfig.initConfig(); + expect(buildConfig.watchOptions.files).toEqual(['/templates', '/lang']); + }); }); describe('production method', () => { it('should resolve successfully for "valid-config"', async () => { @@ -40,6 +47,16 @@ describe('BuildConfigManager integration tests', () => { await promisify(buildConfig.production.bind(buildConfig))(); buildConfig.stopWorker(); }); + it('should resolve successfully for "esm-config"', async () => { + const buildConfig = new BuildConfigManager({ + workDir: `${cwd}/test/_mocks/build-config/esm-config`, + }); + await buildConfig.initConfig(); + buildConfig.initWorker(); + expect(buildConfig.production).toBeInstanceOf(Function); + await promisify(buildConfig.production.bind(buildConfig))(); + buildConfig.stopWorker(); + }); it('should reject with "worker terminated" message for "noworker-config"', async () => { const buildConfig = new BuildConfigManager({ workDir: `${cwd}/test/_mocks/build-config/noworker-config`, @@ -93,5 +110,16 @@ describe('BuildConfigManager integration tests', () => { }); buildConfig.stopWorker(); }); + it('should reload the browser when a message "reload" is received from stencil.conf.mjs (esm-config)', async () => { + const buildConfig = new BuildConfigManager({ + workDir: `${cwd}/test/_mocks/build-config/esm-config`, + }); + await buildConfig.initConfig(); + expect(buildConfig.development).toBeInstanceOf(Function); + await new Promise((done) => { + buildConfig.initWorker().development({ reload: done }); + }); + buildConfig.stopWorker(); + }); }); }); diff --git a/lib/stencil-bundle.js b/lib/stencil-bundle.js index ee4c2838..8687d6ff 100644 --- a/lib/stencil-bundle.js +++ b/lib/stencil-bundle.js @@ -34,6 +34,7 @@ const PATHS_TO_ZIP = [ { pattern: '.scss-lint.yml' }, { pattern: 'stencil.conf.cjs' }, { pattern: 'stencil.conf.js' }, + { pattern: 'stencil.conf.mjs' }, { pattern: 'templates/**/*' }, { pattern: 'webpack.*.js' }, ]; diff --git a/test/_mocks/build-config/esm-config/stencil.conf.cjs b/test/_mocks/build-config/esm-config/stencil.conf.cjs new file mode 100644 index 00000000..773f57a6 --- /dev/null +++ b/test/_mocks/build-config/esm-config/stencil.conf.cjs @@ -0,0 +1,9 @@ +// Decoy config: stencil.conf.mjs in this directory must take precedence. +// If the CLI loads or forks this file instead, the esm-config tests fail +// (wrong watchOptions, and no worker protocol so the fork exits immediately). +const watchOptions = { + files: ['/cjs-config-should-not-be-loaded'], + ignored: [], +}; + +module.exports = { watchOptions }; diff --git a/test/_mocks/build-config/esm-config/stencil.conf.mjs b/test/_mocks/build-config/esm-config/stencil.conf.mjs new file mode 100644 index 00000000..b38b6ef2 --- /dev/null +++ b/test/_mocks/build-config/esm-config/stencil.conf.mjs @@ -0,0 +1,50 @@ +/** + * Watch options for the core watcher + * @type {{files: string[], ignored: string[]}} + */ +// eslint-disable-next-line import/prefer-default-export +export const watchOptions = { + // If files in these directories change, reload the page. + files: [ + '/templates', + '/lang', + ], + + // Do not watch files in these directories + ignored: [ + '/assets/scss', + '/assets/css', + '/assets/dist', + ], +}; + +/** + * Watch any custom files and trigger a rebuild + */ +function development() { + // Rebuild the bundle once at bootup + setTimeout(() => process.send('reload'), 10); +} + +/** + * Hook into the `stencil bundle` command and build your files before they are packaged as a .zip + */ +function production() { + // Rebuild the bundle once at bootup + setTimeout(() => process.send('done'), 10); +} + +if (process.send) { + // running as a forked worker + process.on('message', message => { + if (message === 'development') { + development(); + } + + if (message === 'production') { + production(); + } + }); + + process.send('ready'); +}