├── .editorconfig ├── .gitignore ├── .npmignore ├── LICENSE.md ├── README.md ├── package.json ├── pnpm-lock.yaml ├── src └── stimulus-controller-resolver.js └── tests └── stimulus-controller-resolver.test.js /.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | 12 | [*.md] 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | /dist 3 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .editorconfig 2 | .github 3 | demo 4 | src 5 | test -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Daniel Diekmeier 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Stimulus Controller Resolver 2 | 3 | If you have a lot of Stimulus Controllers that import other modules, the size can really start to add up. (I have some Controllers that mount [Svelte](https://svelte.dev) components!) Wouldn't it be great if you could lazy load some of your Controllers? 4 | 5 | This package allows you to supply a custom (async!) resolver function for your controllers. It is supposed to provide an alternative to putting all your controllers into your main bundle. 6 | 7 | 8 | ## Installation 9 | 10 | ```sh 11 | npm i stimulus-controller-resolver 12 | ``` 13 | 14 | 15 | ## Usage 16 | 17 | To load all your Controllers lazily: 18 | 19 | ```js 20 | import { Application } from '@hotwired/stimulus' 21 | import StimulusControllerResolver from 'stimulus-controller-resolver' 22 | 23 | const application = Application.start() 24 | 25 | StimulusControllerResolver.install(application, async controllerName => ( 26 | (await import(`./controllers/${controllerName}-controller.js`)).default 27 | )) 28 | ``` 29 | 30 | Depending on your configuration, your bundler should then split out all the files in the `controllers`-folder into seperate chunks. 31 | 32 | If you want to preload _some_ controllers but still load all the other ones lazily, you can use the good old [`application.register`](https://stimulusjs.org/handbook/installing#using-other-build-systems): 33 | 34 | ```js 35 | import ImportantController from './controllers/important-controller.js' 36 | import CrucialController from './controllers/crucial-controller.js' 37 | 38 | application.register('important-controller', ImportantController) 39 | application.register('crucial-controller', CrucialController) 40 | 41 | StimulusControllerResolver.install(application, async controllerName => ( 42 | (await import(`./controllers/${controllerName}-controller.js`)).default 43 | )) 44 | ``` 45 | 46 | 47 | ### With Vite Glob Import 48 | 49 | If you're using Vite, you can make use of [Vite's Glob Import](https://vitejs.dev/guide/features.html#glob-import). This package exports an additional function called `createViteGlobResolver` that handles this for you. Pass it one or more globs: 50 | 51 | ```js 52 | import { Application } from "@hotwired/stimulus" 53 | import StimulusControllerResolver, { createViteGlobResolver } from 'stimulus-controller-resolver' 54 | 55 | const application = Application.start() 56 | 57 | StimulusControllerResolver.install(application, createViteGlobResolver( 58 | import.meta.glob('../controllers/*-controller.js'), 59 | import.meta.glob('../../components/**/*-controller.js') 60 | )) 61 | ``` 62 | 63 | By default, the filenames will be transformed according to the [Stimulus Identifier Rules](https://stimulus.hotwired.dev/reference/controllers#identifiers), starting from the `controllers` or `components` folders: 64 | 65 | | Path | Stimulus Identifier | 66 | |------------------------------------------------|---------------------| 67 | | ../controllers/stickiness-controller.js | `stickiness` | 68 | | ../../components/deep_dir/slider-controller.js | `deep-dir--slider` | 69 | 70 | If `process.env.NODE_ENV === 'development'`, it also prints a helpful message if you request a controller that is not available: 71 | 72 | ``` 73 | Stimulus Controller Resolver can't resolve "missing". Available: ['this-one', 'and-this-one'] 74 | ``` 75 | 76 | #### Custom Regex 77 | 78 | If you need more flexibility, you can provide your own Regex to extract the identifiers from your paths. The first match will be transformed according to the aforementioned Stimulus Identifier Rules. 79 | 80 | ```js 81 | StimulusControllerResolver.install(application, createViteGlobResolver([{ 82 | glob: import.meta.glob('../sprinkles/**/*_controller.js'), 83 | regex: /^.+sprinkles\/(.+?)_controller.js$/, 84 | }])) 85 | ``` 86 | 87 | #### Custom Identifier Transformation 88 | 89 | If you _still_ need more flexibility, you can provide your own `toIdentifier` function. For example, if you wanted `../cards/album/stimulus_controller.js` to be available as `album-card`, you could do: 90 | 91 | ```js 92 | StimulusControllerResolver.install(application, createViteGlobResolver({ 93 | glob: import.meta.glob('../cards/*/stimulus_controller.js'), 94 | toIdentifier(key) { 95 | return key.split("cards/")[1].split("/stimulus_controller")[0] + "-card" 96 | }, 97 | })) 98 | ``` 99 | 100 | #### Mix and match 101 | 102 | Combine the different ways however you need! 103 | 104 | ```js 105 | StimulusControllerResolver.install(application, createViteGlobResolver( 106 | import.meta.glob('../controllers/*-controller.js'), 107 | { 108 | glob: import.meta.glob('../shop/components/*/stimulus_controller.js'), 109 | regex: /example/ 110 | }, 111 | { 112 | glob: import.meta.glob('../shop/components/*/stimulus_controller.js'), 113 | toIdentifier(key) { return key.toLowerCase() } 114 | }, 115 | )) 116 | ``` 117 | 118 | > [!TIP] 119 | > If after all this, you need _even more_ flexibility, you can always implement your completely own custom resolver function, as described above. 120 | 121 | 122 | ## API 123 | 124 | ```js 125 | StimulusControllerResolver.install(application, resolverFn) 126 | ``` 127 | 128 | - `application`: This is your instance of `Stimulus.Application`. 129 | - `resolverFn(controllerName): Controller`: A function that receives the name of the controller (that's the part you write in `data-controller="this-is-the-name"`) and returns the `Controller` class you want to use for this `controllerName`. This will only be called the first time each `controllerName` is encountered. 130 | 131 | `install()` returns an instance of `StimulusControllerResolver`, on which you can call: 132 | 133 | ```js 134 | instance.stop() // to stop getting new controller definitions 135 | 136 | // and 137 | 138 | instance.start() // to start again 139 | ``` 140 | 141 | `install()` will automatically call `start()`, so most of the time you shouldn't have to do anything. 142 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "stimulus-controller-resolver", 3 | "version": "3.2.0", 4 | "description": "Resolve your Stimulus Controllers any way you want – even lazy!", 5 | "exports": "./src/stimulus-controller-resolver.js", 6 | "type": "module", 7 | "files": [ 8 | "src" 9 | ], 10 | "prettier": { 11 | "trailingComma": "all", 12 | "tabWidth": 2, 13 | "semi": false 14 | }, 15 | "scripts": { 16 | "deps:update": "corepack use pnpm@latest && pnpm update --latest --interactive", 17 | "prettier": "prettier --write src/*", 18 | "test": "node --test ./tests/**/*" 19 | }, 20 | "repository": { 21 | "type": "git", 22 | "url": "git+ssh://git@github.com/danieldiekmeier/stimulus-controller-resolver.git" 23 | }, 24 | "keywords": [ 25 | "hotwire", 26 | "hotwired", 27 | "stimulus", 28 | "stimulusjs", 29 | "controller", 30 | "lazy" 31 | ], 32 | "author": "Daniel Diekmeier", 33 | "license": "MIT", 34 | "bugs": { 35 | "url": "https://github.com/danieldiekmeier/stimulus-controller-resolver/issues" 36 | }, 37 | "homepage": "https://github.com/danieldiekmeier/stimulus-controller-resolver#readme", 38 | "devDependencies": { 39 | "prettier": "^3.5.3" 40 | }, 41 | "peerDependencies": { 42 | "@hotwired/stimulus": "3.x" 43 | }, 44 | "packageManager": "pnpm@10.5.2+sha512.da9dc28cd3ff40d0592188235ab25d3202add8a207afbedc682220e4a0029ffbff4562102b9e6e46b4e3f9e8bd53e6d05de48544b0c57d4b0179e22c76d1199b" 45 | } 46 | -------------------------------------------------------------------------------- /pnpm-lock.yaml: -------------------------------------------------------------------------------- 1 | lockfileVersion: '9.0' 2 | 3 | settings: 4 | autoInstallPeers: true 5 | excludeLinksFromLockfile: false 6 | 7 | importers: 8 | 9 | .: 10 | dependencies: 11 | '@hotwired/stimulus': 12 | specifier: 3.x 13 | version: 3.2.2 14 | devDependencies: 15 | prettier: 16 | specifier: ^3.5.3 17 | version: 3.5.3 18 | 19 | packages: 20 | 21 | '@hotwired/stimulus@3.2.2': 22 | resolution: {integrity: sha512-eGeIqNOQpXoPAIP7tC1+1Yc1yl1xnwYqg+3mzqxyrbE5pg5YFBZcA6YoTiByJB6DKAEsiWtl6tjTJS4IYtbB7A==} 23 | 24 | prettier@3.5.3: 25 | resolution: {integrity: sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==} 26 | engines: {node: '>=14'} 27 | hasBin: true 28 | 29 | snapshots: 30 | 31 | '@hotwired/stimulus@3.2.2': {} 32 | 33 | prettier@3.5.3: {} 34 | -------------------------------------------------------------------------------- /src/stimulus-controller-resolver.js: -------------------------------------------------------------------------------- 1 | import { AttributeObserver } from "@hotwired/stimulus" 2 | 3 | export default class StimulusControllerResolver { 4 | constructor(application, resolverFn) { 5 | this.application = application 6 | this.loadingControllers = {} 7 | this.resolverFn = resolverFn 8 | 9 | this.loadStimulusControllers = this.loadStimulusControllers.bind(this) 10 | 11 | this.observer = new AttributeObserver( 12 | application.element, 13 | application.schema.controllerAttribute, 14 | { 15 | elementMatchedAttribute: this.loadStimulusControllers, 16 | elementAttributeValueChanged: this.loadStimulusControllers, 17 | }, 18 | ) 19 | } 20 | 21 | start() { 22 | this.observer.start() 23 | } 24 | 25 | stop() { 26 | this.observer.stop() 27 | } 28 | 29 | static install(application, resolverFn) { 30 | const instance = new StimulusControllerResolver(application, resolverFn) 31 | instance.start() 32 | return instance 33 | } 34 | 35 | loadStimulusControllers(element) { 36 | const controllerNames = element 37 | .getAttribute(this.application.schema.controllerAttribute) 38 | .split(/\s+/) 39 | 40 | controllerNames.forEach((controllerName) => 41 | this.loadController(controllerName), 42 | ) 43 | } 44 | 45 | async loadController(controllerName) { 46 | if ( 47 | !this.loadingControllers[controllerName] && 48 | !this.application.router.modulesByIdentifier.has(controllerName) 49 | ) { 50 | this.loadingControllers[controllerName] = true 51 | 52 | const controllerDefinition = await this.resolverFn(controllerName) 53 | 54 | if (controllerDefinition) { 55 | this.application.register(controllerName, controllerDefinition) 56 | } 57 | 58 | delete this.loadingControllers[controllerName] 59 | } 60 | } 61 | } 62 | 63 | export function createViteGlobResolver(...globsOrConfigs) { 64 | const globResults = globsOrConfigs.map(normalizeGlobConfig) 65 | const controllerLoaders = mapGlobKeysToIdentifiers(globResults) 66 | 67 | const resolverFn = async (controllerName) => { 68 | const loader = controllerLoaders[controllerName] 69 | 70 | if (process.env.NODE_ENV === "development") { 71 | if (!loader) { 72 | console.warn( 73 | `Stimulus Controller Resolver can't resolve "${controllerName}". Available:`, 74 | Object.keys(controllerLoaders), 75 | ) 76 | return 77 | } 78 | } 79 | 80 | return (await loader()).default 81 | } 82 | 83 | return resolverFn 84 | } 85 | 86 | // Vite's glob keys include the complete path of each file, but we need the 87 | // Stimulus identifiers. This function merges an array of glob results into one 88 | // object, where the key is the Stimulus identifier. 89 | // Example: 90 | // mapGlobKeysToIdentifiers([ 91 | // {glob: { "./a_controller.js": fn1 }, toIdentifier, regex}, 92 | // {glob: { "./b_controller.js": fn2 }, toIdentifier, regex} 93 | // ]) 94 | // => { a: fn1, b: fn2 } 95 | export function mapGlobKeysToIdentifiers(globResults) { 96 | const acc = [] 97 | 98 | globResults.forEach(({ glob, toIdentifier, regex }) => { 99 | Object.entries(glob).forEach(([key, controllerFn]) => { 100 | acc[toIdentifier(key, regex)] = controllerFn 101 | }) 102 | }) 103 | 104 | return acc 105 | } 106 | 107 | export function normalizeGlobConfig(globOrConfig) { 108 | const normalized = { 109 | glob: globOrConfig, 110 | toIdentifier: identifierForGlobKey, 111 | regex: CONTROLLER_FILENAME_REGEX, 112 | } 113 | 114 | if (Object(globOrConfig).hasOwnProperty("glob")) { 115 | return { ...normalized, ...globOrConfig } 116 | } else { 117 | return normalized 118 | } 119 | } 120 | 121 | // export const CONTROLLER_FILENAME_REGEX = 122 | // /^(?:.*?(?:controllers)\/|\.?\.\/)?(.+?)(?:[_-]controller\..+?)$/ 123 | export const CONTROLLER_FILENAME_REGEX = 124 | /^(?:.*?(?:controllers|components)\/|\.?\.\/)?(.+?)(?:[_-]controller\..+?)$/ 125 | 126 | // Yoinked from: https://github.com/ElMassimo/stimulus-vite-helpers/blob/e349b0d14d5585773153a178c8fe129821bbf786/src/index.ts#L21-L25 127 | export function identifierForGlobKey(key, regex = CONTROLLER_FILENAME_REGEX) { 128 | const logicalName = (key.match(regex) || [])[1] 129 | if (logicalName) return logicalName.replace(/_/g, "-").replace(/\//g, "--") 130 | } 131 | -------------------------------------------------------------------------------- /tests/stimulus-controller-resolver.test.js: -------------------------------------------------------------------------------- 1 | import { describe, it } from "node:test" 2 | import { 3 | createViteGlobResolver, 4 | identifierForGlobKey, 5 | } from "../src/stimulus-controller-resolver.js" 6 | import assert from "node:assert" 7 | 8 | function stub(v) { 9 | return async () => { 10 | return { default: v } 11 | } 12 | } 13 | 14 | describe("createViteGlobResolver", () => { 15 | it("maps paths to controllers", async () => { 16 | const resolverFn = createViteGlobResolver({ 17 | "./a_controller.js": stub("a"), 18 | }) 19 | 20 | assert.equal(await resolverFn("a"), "a") 21 | }) 22 | 23 | it("overrides with later resolvers", async () => { 24 | const resolverFn = createViteGlobResolver( 25 | { 26 | "./a_controller.js": stub("a"), 27 | "./b_controller.js": stub("b"), 28 | }, 29 | { 30 | "./b_controller.js": stub("override b"), 31 | }, 32 | ) 33 | 34 | assert.equal(await resolverFn("a"), "a") 35 | assert.equal(await resolverFn("b"), "override b") 36 | }) 37 | 38 | it("can take options as last argument", async () => { 39 | const resolverFn = createViteGlobResolver( 40 | { 41 | glob: { "./a_controller.js": stub("a") }, 42 | }, 43 | { 44 | glob: { "./b_controller.js": stub("b") }, 45 | }, 46 | ) 47 | 48 | assert.equal(await resolverFn("a"), "a") 49 | assert.equal(await resolverFn("b"), "b") 50 | }) 51 | 52 | it("accepts a custom regex", async () => { 53 | const resolverFn = createViteGlobResolver({ 54 | glob: { 55 | "../../../components/blogs/app/javascript/sprinkles/blogs/previous_updates_controller.js": 56 | stub("prev"), 57 | }, 58 | regex: /^.+sprinkles\/(.+?)_controller.js$/, 59 | }) 60 | 61 | assert.equal(await resolverFn("blogs--previous-updates"), "prev") 62 | }) 63 | 64 | it("accepts a custom toIdentifier function", async () => { 65 | const resolverFn = createViteGlobResolver({ 66 | glob: { 67 | "../cards/album/stimulus_controller.js": stub("AlbumCard"), 68 | }, 69 | toIdentifier(key) { 70 | return key.split("cards/")[1].split("/stimulus_controller")[0] + "-card" 71 | }, 72 | }) 73 | 74 | assert.equal(await resolverFn("album-card"), "AlbumCard") 75 | }) 76 | 77 | it("can mix and match", async () => { 78 | const resolverFn = createViteGlobResolver( 79 | { 80 | "./a_controller.js": stub("a"), 81 | }, 82 | { 83 | glob: { "./b_controller.js": stub("b") }, 84 | regex: /\/(.+?)_controller.js$/, 85 | }, 86 | { 87 | glob: { "./c_controller.js": stub("c") }, 88 | toIdentifier: (key) => "c", 89 | }, 90 | ) 91 | 92 | assert.equal(await resolverFn("a"), "a") 93 | assert.equal(await resolverFn("b"), "b") 94 | assert.equal(await resolverFn("c"), "c") 95 | }) 96 | }) 97 | 98 | describe("identifierForGlobKey", () => { 99 | it("transforms a path to the controller identifier", () => { 100 | const mapping = { 101 | "./a_controller.js": "a", 102 | "./b_controller.ts": "b", 103 | "../app/javascript/controllers/blogs/previous_updates_controller.js": 104 | "blogs--previous-updates", 105 | } 106 | 107 | Object.entries(mapping).forEach(([path, correct]) => { 108 | assert.equal(identifierForGlobKey(path), correct) 109 | }) 110 | }) 111 | }) 112 | --------------------------------------------------------------------------------