diff --git a/fixtures/stimulus/assets/app.js b/fixtures/stimulus/assets/app.js new file mode 100644 index 00000000..1bf81ffd --- /dev/null +++ b/fixtures/stimulus/assets/app.js @@ -0,0 +1,4 @@ +import { startStimulusApp } from '@symfony/stimulus-bridge'; +import '@symfony/autoimport'; + +export const app = startStimulusApp(require.context('./controllers', true, /\.(j|t)sx?$/)); diff --git a/fixtures/stimulus/assets/controllers.json b/fixtures/stimulus/assets/controllers.json new file mode 100644 index 00000000..85b0431a --- /dev/null +++ b/fixtures/stimulus/assets/controllers.json @@ -0,0 +1,14 @@ +{ + "controllers": { + "@symfony/mock-module": { + "mock": { + "webpackMode": "lazy", + "enabled": true, + "autoimport": { + "@symfony/mock-module/dist/style.css": true + } + } + } + }, + "entrypoints": [] +} diff --git a/fixtures/stimulus/assets/controllers/hello_controller.js b/fixtures/stimulus/assets/controllers/hello_controller.js new file mode 100644 index 00000000..cb8bdaeb --- /dev/null +++ b/fixtures/stimulus/assets/controllers/hello_controller.js @@ -0,0 +1,7 @@ +import { Controller } from 'stimulus'; + +export default class extends Controller { + connect() { + console.log('app-controller'); + } +} diff --git a/fixtures/stimulus/autoimport/index.js b/fixtures/stimulus/autoimport/index.js new file mode 100644 index 00000000..7eeeb7b2 --- /dev/null +++ b/fixtures/stimulus/autoimport/index.js @@ -0,0 +1 @@ +import '@symfony/mock-module/dist/style.css'; diff --git a/fixtures/stimulus/autoimport/package.json b/fixtures/stimulus/autoimport/package.json new file mode 100644 index 00000000..168c0cb2 --- /dev/null +++ b/fixtures/stimulus/autoimport/package.json @@ -0,0 +1,6 @@ +{ + "name": "@symfony/autoimport", + "license": "MIT", + "version": "1.0.0", + "main": "./index.js" +} diff --git a/fixtures/stimulus/controllers/index.js b/fixtures/stimulus/controllers/index.js new file mode 100644 index 00000000..5566df01 --- /dev/null +++ b/fixtures/stimulus/controllers/index.js @@ -0,0 +1,3 @@ +module.exports = { + '@symfony/mock-module/mock': import(/* webpackMode: "eager" */ '@symfony/mock-module/dist/controller'), +}; diff --git a/fixtures/stimulus/controllers/package.json b/fixtures/stimulus/controllers/package.json new file mode 100644 index 00000000..7500b21c --- /dev/null +++ b/fixtures/stimulus/controllers/package.json @@ -0,0 +1,6 @@ +{ + "name": "@symfony/controllers", + "license": "MIT", + "version": "1.0.0", + "main": "./index.js" +} diff --git a/fixtures/stimulus/mock-module/dist/controller.js b/fixtures/stimulus/mock-module/dist/controller.js new file mode 100644 index 00000000..89d3d6e0 --- /dev/null +++ b/fixtures/stimulus/mock-module/dist/controller.js @@ -0,0 +1,7 @@ +import { Controller } from 'stimulus'; + +export default class extends Controller { + connect() { + console.log('mock-module-controller'); + } +} diff --git a/fixtures/stimulus/mock-module/dist/style.css b/fixtures/stimulus/mock-module/dist/style.css new file mode 100644 index 00000000..208d16d4 --- /dev/null +++ b/fixtures/stimulus/mock-module/dist/style.css @@ -0,0 +1 @@ +body {} diff --git a/fixtures/stimulus/mock-module/package.json b/fixtures/stimulus/mock-module/package.json new file mode 100644 index 00000000..c0d70e1c --- /dev/null +++ b/fixtures/stimulus/mock-module/package.json @@ -0,0 +1,17 @@ +{ + "name": "@symfony/mock-module", + "license": "MIT", + "version": "1.0.0", + "symfony": { + "controllers": { + "mock": { + "main": "dist/controller.js", + "webpackMode": "eager", + "enabled": true, + "autoimport": { + "@symfony/mock-module/dist/style.css": true + } + } + } + } +} diff --git a/index.js b/index.js index cc9b1a2d..8c5a8992 100644 --- a/index.js +++ b/index.js @@ -1018,6 +1018,17 @@ class Encore { return this; } + /** + * If enabled, the Stimulus bridge is used to load Stimulus controllers from PHP packages. + * + * @returns {Encore} + */ + enableStimulusBridge(controllerJsonPath) { + webpackConfig.enableStimulusBridge(controllerJsonPath); + + return this; + } + /** * If enabled, the react preset is added to Babel. * diff --git a/lib/WebpackConfig.js b/lib/WebpackConfig.js index 770471b2..9085adfd 100644 --- a/lib/WebpackConfig.js +++ b/lib/WebpackConfig.js @@ -105,6 +105,7 @@ class WebpackConfig { this.useLessLoader = false; this.useStylusLoader = false; this.useSassLoader = false; + this.useStimulusBridge = false; this.useReact = false; this.usePreact = false; this.useVueLoader = false; @@ -121,6 +122,9 @@ class WebpackConfig { resolveUrlLoader: true, resolveUrlLoaderOptions: {} }; + this.stimulusOptions = { + controllerJsonPath: null, + }; this.preactOptions = { preactCompat: false }; @@ -680,6 +684,24 @@ class WebpackConfig { this.stylusLoaderOptionsCallback = stylusLoaderOptionsCallback; } + enableStimulusBridge(controllerJsonPath) { + this.useStimulusBridge = true; + + if (!fs.existsSync(controllerJsonPath)) { + throw new Error(`File "${controllerJsonPath}" could not be found.`); + } + + // Add configured entrypoints + const controllersData = JSON.parse(fs.readFileSync(controllerJsonPath)); + const rootDir = path.dirname(path.resolve(controllerJsonPath)); + + for (let name in controllersData.entrypoints) { + this.addEntry(name, rootDir + '/' + controllersData.entrypoints[name]); + } + + this.stimulusOptions.controllersJsonPath = controllerJsonPath; + } + enableReactPreset() { this.useReact = true; } diff --git a/lib/config-generator.js b/lib/config-generator.js index e96bd4ba..7cc37676 100644 --- a/lib/config-generator.js +++ b/lib/config-generator.js @@ -40,6 +40,7 @@ const assetOutputDisplay = require('./plugins/asset-output-display'); const notifierPluginUtil = require('./plugins/notifier'); const sharedEntryConcatPuginUtil = require('./plugins/shared-entry-concat'); const PluginPriorities = require('./plugins/plugin-priorities'); +const stimulusBridge = require('./plugins/stimulus-bridge'); const applyOptionsCallback = require('./utils/apply-options-callback'); const sharedEntryTmpName = require('./utils/sharedEntryTmpName'); const copyEntryTmpName = require('./utils/copyEntryTmpName'); @@ -457,6 +458,8 @@ class ConfigGenerator { variableProviderPluginUtil(plugins, this.webpackConfig); + stimulusBridge(plugins, this.webpackConfig); + cleanPluginUtil(plugins, this.webpackConfig); definePluginUtil(plugins, this.webpackConfig); diff --git a/lib/plugins/stimulus-bridge.js b/lib/plugins/stimulus-bridge.js new file mode 100644 index 00000000..9800625a --- /dev/null +++ b/lib/plugins/stimulus-bridge.js @@ -0,0 +1,27 @@ +/* + * This file is part of the Symfony Webpack Encore package. + * + * (c) Fabien Potencier + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +'use strict'; + +const WebpackConfig = require('../WebpackConfig'); //eslint-disable-line no-unused-vars +const createPlugin = require('@symfony/stimulus-bridge/webpack-helper'); +const fs = require('fs'); + +/** + * @param {Array} plugins + * @param {WebpackConfig} webpackConfig + * @return {void} + */ +module.exports = function(plugins, webpackConfig) { + if (webpackConfig.useStimulusBridge) { + plugins.push({ + plugin: createPlugin(JSON.parse(fs.readFileSync(webpackConfig.stimulusOptions.controllersJsonPath))), + }); + } +}; diff --git a/package.json b/package.json index 8610d28b..ad601c18 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,7 @@ "webpack-dev-server": "^3.1.14", "webpack-manifest-plugin": "^2.0.2", "webpack-sources": "^1.3.0", + "webpack-virtual-modules": "^0.2.2", "yargs-parser": "^18.1.3" }, "devDependencies": { @@ -58,6 +59,10 @@ "@babel/plugin-transform-react-jsx": "^7.0.0", "@babel/preset-react": "^7.0.0", "@babel/preset-typescript": "^7.0.0", + "@symfony/mock-module": "file:fixtures/stimulus/mock-module", + "@symfony/controllers": "file:fixtures/stimulus/controllers", + "@symfony/autoimport": "file:fixtures/stimulus/autoimport", + "@symfony/stimulus-bridge": "^1.0.0", "@vue/babel-helper-vue-jsx-merge-props": "^1.0.0-beta.3", "@vue/babel-preset-jsx": "^1.0.0-beta.3", "@vue/compiler-sfc": "^3.0.0-beta.9", @@ -87,6 +92,7 @@ "sass": "^1.17.0", "sass-loader": "^9.0.1", "sinon": "^9.0.2", + "stimulus": "^1.1.1", "strip-ansi": "^6.0.0", "stylus": "^0.54.5", "stylus-loader": "^3.0.2", diff --git a/test/functional.js b/test/functional.js index 64951b53..ce091ac6 100644 --- a/test/functional.js +++ b/test/functional.js @@ -2072,6 +2072,34 @@ module.exports = { }); }); + it('Symfony - Stimulus standard app is built correctly', () => { + const appDir = testSetup.createTestAppDir(); + + const config = testSetup.createWebpackConfig(appDir, 'www/build', 'dev'); + config.enableSingleRuntimeChunk(); + config.setPublicPath('/build'); + config.addEntry('main', './stimulus/assets/app.js'); + config.enableStimulusBridge(__dirname + '/../fixtures/stimulus/assets/controllers.json'); + config.configureBabel(function(config) { + config.plugins.push('@babel/plugin-proposal-class-properties'); + }); + + testSetup.runWebpack(config, (webpackAssert) => { + expect(config.outputPath).to.be.a.directory().with.deep.files([ + 'main.js', + 'main.css', + 'manifest.json', + 'entrypoints.json', + 'runtime.js', + ]); + + // test controllers and style are shipped + webpackAssert.assertOutputFileContains('main.js', 'app-controller'); + webpackAssert.assertOutputFileContains('main.js', 'mock-module-controller'); + webpackAssert.assertOutputFileContains('main.css', 'body {}'); + }); + }); + describe('copyFiles() allows to copy files and folders', () => { it('Single file copy', (done) => { const config = createWebpackConfig('www/build', 'production');