├── .gitignore ├── package.json ├── MIT-LICENSE ├── src └── index.js ├── examples └── esbuild.config.mjs └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | yarn-error.log 3 | .DS_Store 4 | *.swp 5 | *.swo 6 | *~ 7 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "esbuild-rails", 3 | "version": "1.0.7", 4 | "description": "Esbuild plugin for Rails applications", 5 | "main": "src/index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "repository": { 10 | "type": "git", 11 | "url": "git+https://github.com/excid3/esbuild-rails.git" 12 | }, 13 | "keywords": [ 14 | "esbuild", 15 | "rails", 16 | "stimulus", 17 | "hotwire", 18 | "import", 19 | "glob" 20 | ], 21 | "author": "Chris Oliver ", 22 | "license": "MIT", 23 | "bugs": { 24 | "url": "https://github.com/excid3/esbuild-rails/issues" 25 | }, 26 | "homepage": "https://github.com/excid3/esbuild-rails#readme", 27 | "np": { 28 | "tests": false 29 | }, 30 | "peerDependencies": { 31 | "esbuild": "*" 32 | }, 33 | "dependencies": { 34 | "fast-glob": "^3.2.12" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /MIT-LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2021 Chris Oliver 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const fg = require('fast-glob') 3 | 4 | // Transform filenames to controller names 5 | // [ './admin/hello_world_controller.js', ... ] 6 | // [ 'admin--hello-world', ... ] 7 | function convertFilenameToControllerName(filename) { 8 | return filename 9 | .replace(/^\.\//, "") // Remove ./ prefix 10 | .replace(/_controller.[j|t]s$/, "") // Strip _controller.js extension 11 | .replace(/\//g, "--") // Replace folders with -- namespaces 12 | .replace(/_/g, '-') // 13 | } 14 | 15 | // This plugin adds support for globs like "./**/*" to import an entire directory 16 | // We can use this to import arbitrary files or Stimulus controllers and ActionCable channels 17 | const railsPlugin = (options = { matcher: /.+\..+/ }) => ({ 18 | name: 'rails', 19 | setup: (build) => { 20 | build.onResolve({ filter: /\*/ }, async (args) => { 21 | if (args.resolveDir === '') { 22 | return; // Ignore unresolvable paths 23 | } 24 | 25 | return { 26 | // make sure that imports are properly scoped to directories that are requested from 27 | // otherwise results get overwritten 28 | path: path.resolve(args.resolveDir, args.path), 29 | namespace: 'rails', 30 | pluginData: { 31 | path: args.path, 32 | resolveDir: args.resolveDir, 33 | }, 34 | }; 35 | }); 36 | 37 | build.onLoad({ filter: /.*/, namespace: 'rails' }, async (args) => { 38 | // Get a list of all files in the directory 39 | let files = ( 40 | fg.sync(args.pluginData.path, { 41 | cwd: args.pluginData.resolveDir, 42 | }) 43 | ) 44 | 45 | const watchedDirs = new Set(); 46 | watchedDirs.add(args.pluginData.resolveDir); 47 | 48 | // Filter to match the import 49 | files = files.sort().filter(path => options.matcher.test(path)); 50 | 51 | // Add directories of matched files to watchedDirs 52 | files.forEach(file => { 53 | const dir = path.dirname(path.resolve(args.pluginData.resolveDir, file)); 54 | watchedDirs.add(dir); 55 | }); 56 | 57 | const controllerNames = files.map(convertFilenameToControllerName) 58 | 59 | const importerCode = ` 60 | ${files 61 | .map((module, index) => `import * as module${index} from './${module}'`) 62 | .join(';')} 63 | const modules = [${controllerNames 64 | .map((module, index) => `{name: '${module}', module: module${index}, filename: '${files[index]}'}`) 65 | .join(',')}] 66 | export default modules; 67 | `; 68 | 69 | return { contents: importerCode, resolveDir: args.pluginData.resolveDir, watchDirs: Array.from(watchedDirs) }; 70 | }); 71 | }, 72 | }); 73 | 74 | module.exports = railsPlugin 75 | -------------------------------------------------------------------------------- /examples/esbuild.config.mjs: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | // Requires esbuild 0.17+ & chokidar v4+ 3 | // 4 | // Esbuild is configured with 3 modes: 5 | // 6 | // `yarn build` - Build JavaScript and exit 7 | // `yarn build --watch` - Rebuild JavaScript on change 8 | // `yarn build --reload` - Reloads page when views, JavaScript, or stylesheets change 9 | // 10 | // Minify is enabled when "RAILS_ENV=production" 11 | // Sourcemaps are enabled in non-production environments 12 | 13 | import * as esbuild from "esbuild" 14 | import path from "path" 15 | import rails from "esbuild-rails" 16 | import chokidar from "chokidar" 17 | import http from "http" 18 | import { setTimeout } from "timers/promises" 19 | 20 | const clients = [] 21 | const entryPoints = [ 22 | "application.js" 23 | ] 24 | const watchDirectories = [ 25 | "./app/javascript", 26 | "./app/views", 27 | "./app/assets/builds", // Wait for cssbundling changes 28 | ] 29 | const config = { 30 | absWorkingDir: path.join(process.cwd(), "app/javascript"), 31 | bundle: true, 32 | entryPoints: entryPoints, 33 | minify: process.env.RAILS_ENV == "production", 34 | outdir: path.join(process.cwd(), "app/assets/builds"), 35 | plugins: [rails()], 36 | sourcemap: process.env.RAILS_ENV != "production" 37 | } 38 | 39 | async function buildAndReload() { 40 | // Foreman & Overmind assign a separate PORT for each process 41 | const port = parseInt(process.env.PORT) 42 | const context = await esbuild.context({ 43 | ...config, 44 | banner: { 45 | js: ` (() => new EventSource("http://localhost:${port}").onmessage = () => location.reload())();`, 46 | } 47 | }) 48 | 49 | // Reload uses an HTTP server as an even stream to reload the browser 50 | http 51 | .createServer((req, res) => { 52 | return clients.push( 53 | res.writeHead(200, { 54 | "Content-Type": "text/event-stream", 55 | "Cache-Control": "no-cache", 56 | "Access-Control-Allow-Origin": "*", 57 | Connection: "keep-alive", 58 | }) 59 | ) 60 | }) 61 | .listen(port) 62 | 63 | await context.rebuild() 64 | console.log("[reload] initial build succeeded") 65 | 66 | let ready = false 67 | chokidar 68 | .watch(watchDirectories) 69 | .on("ready", () => { 70 | console.log("[reload] ready") 71 | ready = true 72 | }) 73 | .on("all", async (event, path) => { 74 | if (ready === false) return 75 | 76 | if (path.includes("javascript")) { 77 | try { 78 | await setTimeout(20) 79 | await context.rebuild() 80 | console.log("[reload] build succeeded") 81 | } catch (error) { 82 | console.error("[reload] build failed", error) 83 | } 84 | } 85 | clients.forEach((res) => res.write("data: update\n\n")) 86 | clients.length = 0 87 | }) 88 | } 89 | 90 | if (process.argv.includes("--reload")) { 91 | buildAndReload() 92 | } else if (process.argv.includes("--watch")) { 93 | let context = await esbuild.context({...config, logLevel: 'info'}) 94 | context.watch() 95 | } else { 96 | esbuild.build(config) 97 | } 98 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![npm version](https://badge.fury.io/js/esbuild-rails.svg)](https://badge.fury.io/js/esbuild-rails) 2 | 3 | # 🛤 esbuild-rails 4 | 5 | Esbuild Rails plugin for easy imports of Stimulus controllers, ActionCable channels, and other Javascript. 6 | 7 | This package is designed to be used with [jsbundling-rails](https://github.com/rails/jsbundling-rails). 8 | 9 | ## ⚙️ Installation 10 | 11 | Install with npm or yarn 12 | 13 | ```bash 14 | yarn add esbuild-rails chokidar 15 | ``` 16 | 17 | ```bash 18 | npm i esbuild-rails chokidar 19 | ``` 20 | 21 | Copy [`examples/esbuild.config.mjs`](examples/esbuild.config.mjs) to your git repository. 22 | 23 | Use npm to add it as the build script (requires npm `>= 7.1`) 24 | 25 | ```sh 26 | npm pkg set scripts.build="node esbuild.config.mjs" 27 | ``` 28 | 29 | or add it manually in `package.json` 30 | 31 | ```javascript 32 | "scripts": { 33 | "build": "node esbuild.config.mjs" 34 | } 35 | ``` 36 | 37 | ## 🧑‍💻 Usage 38 | 39 | Import a folder using globs: 40 | 41 | ```javascript 42 | import "./src/**/*" 43 | ``` 44 | 45 | #### Import Stimulus controllers and register them: 46 | 47 | ```javascript 48 | import { Application } from "@hotwired/stimulus" 49 | const application = Application.start() 50 | 51 | import controllers from "./**/*_controller.js" 52 | controllers.forEach((controller) => { 53 | application.register(controller.name, controller.module.default) 54 | }) 55 | ``` 56 | 57 | #### Importing Stimulus controllers from parent folders (ViewComponents, etc) 58 | 59 | To import Stimulus controllers from parents in other locations, create an `index.js` in the folder that registers controllers and import the `index.js` location. 60 | 61 | For example, we can import Stimulus controller for ViewComponents by creating an `app/components/index.js` file and importing that in your main Stimulus controllers index. 62 | 63 | ```javascript 64 | // app/javascript/controllers/index.js 65 | import { application } from "./application" 66 | 67 | // Import app/components/index.js 68 | import "../../components" 69 | ``` 70 | 71 | ```javascript 72 | // app/components/index.js 73 | import { application } from "../javascript/controllers/application" 74 | 75 | import controllers from "./**/*_controller.js" 76 | controllers.forEach((controller) => { 77 | application.register(controller.name, controller.module.default) 78 | }) 79 | ``` 80 | 81 | #### Import ActionCable channels: 82 | 83 | ```javascript 84 | import "./channels/**/*_channel.js" 85 | ``` 86 | 87 | #### jQuery with esbuild: 88 | 89 | ```bash 90 | yarn add jquery 91 | ``` 92 | 93 | ```javascript 94 | // app/javascript/jquery.js 95 | import jquery from 'jquery'; 96 | window.jQuery = jquery; 97 | window.$ = jquery; 98 | ``` 99 | 100 | ```javascript 101 | //app/javascript/application.js 102 | import "./jquery" 103 | ``` 104 | 105 | Why does this work? `import` in Javascript is hoisted, meaning that `import` is run _before_ the other code regardless of where in the file they are. By splitting the jQuery setup into a separate `import`, we can guarantee that code runs first. Read more [here](https://exploringjs.com/es6/ch_modules.html#_imports-are-hoisted). 106 | 107 | #### jQuery UI with esbuild: 108 | 109 | Follow the jQuery steps above. 110 | 111 | Download [jQuery UI custom build](https://jqueryui.com/download/) and add it to `app/javascript/jquery-ui.js` 112 | 113 | ```javascript 114 | import "./jquery-ui" 115 | 116 | $(function() { 117 | $(document).tooltip() 118 | $("#dialog").dialog() 119 | }) 120 | ``` 121 | 122 | A custom build is required because jQueryUI does not support ESM. 123 | 124 | ## 🙏 Contributing 125 | 126 | If you have an issue you'd like to submit, please do so using the issue tracker in GitHub. In order for us to help you in the best way possible, please be as detailed as you can. 127 | 128 | ## 📝 License 129 | 130 | The gem is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT). 131 | --------------------------------------------------------------------------------