├── .gitignore ├── LICENSE ├── README.md ├── package.json └── plugin.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | package-lock.json 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Sam Ruby 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 | # @rubys/snowpack-plugin-require-context 2 | 3 | A Snowpack plugin implementing webpack's 4 | [require.context](https://webpack.js.org/guides/dependency-management/#requirecontext) 5 | API. The motivation for this was to bring the simplicity of 6 | [Webpack's development of Stimulus applications](https://stimulus.hotwire.dev/handbook/installing#using-webpack) 7 | to Snowpack, but it should be useful for other purposes too. 8 | 9 | This plugin works by rewriting `require.context` calls to generate the 10 | necessary context definitions at build time. It will also watch the 11 | source directories for changes and trigger a rebuild when files change. 12 | 13 | The `input` configuration option described below is optional, and if not 14 | present will cause all `.js` files to be scanned. Only those that contain 15 | calls to `require.context` will be processed by this plugin. 16 | 17 | Limitations of this implementation: 18 | * fourth argument to `require.context` (`sync`) is ignored/not supported 19 | * resulting context object only has a `keys` property, in other words 20 | * it has no `resolve` function 21 | * it has no `id` property 22 | * module definitions does not contain named exports, only `default` 23 | 24 | None of these limitations affect Stimulus.js usage. 25 | 26 | Usage: 27 | 28 | ```bash 29 | npm install @rubys/snowpack-plugin-require-context 30 | ``` 31 | 32 | Then add the plugin to your Snowpack config: 33 | 34 | ```js 35 | // snowpack.config.js 36 | 37 | module.exports = { 38 | plugins: [ 39 | [ 40 | '@rubys/snowpack-plugin-require-context', 41 | { 42 | input: ['application.js'], // files to watch 43 | }, 44 | ], 45 | ], 46 | }; 47 | ``` 48 | 49 | Once the plugin is installed, you can use `require.context` just like 50 | you would with Webpack. An example usage with Stimulus.js: 51 | 52 | ```javascript 53 | // src/application.js 54 | import { Application } from "stimulus" 55 | import { definitionsFromContext } from "stimulus/webpack-helpers" 56 | 57 | const application = Application.start() 58 | const context = require.context("./controllers", true, /\.js$/) 59 | application.load(definitionsFromContext(context)) 60 | ``` 61 | 62 | ## Plugin Options 63 | 64 | | Name | Type | Description | 65 | | :------- | :--------: | :-------------------------------------------------------------------------- | 66 | | `input` | `string[]` | Array of extensions to watch for. 67 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@rubys/snowpack-plugin-require-context", 3 | "version": "0.0.6", 4 | "description": "require-context plugin for Snowpack", 5 | "author": "Sam Ruby ", 6 | "license": "MIT", 7 | "engines": { 8 | "node": ">=12.0.0" 9 | }, 10 | "dependencies": { 11 | "acorn": "^8.0.5", 12 | "acorn-stage3": "^4.0.0", 13 | "acorn-walk": "^8.0.2", 14 | "astring": "^1.6.2" 15 | }, 16 | "main": "plugin.js", 17 | "repository": { 18 | "type": "git", 19 | "url": "https://github.com/rubys/snowpack-plugin-require-context/" 20 | }, 21 | "publishConfig": { 22 | "access": "public" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /plugin.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | // resulting object only has a 'keys' property and default module definitions 4 | // * has no 'resolve' function 5 | // * has no 'id' property 6 | // * module definitions does not contain named exports 7 | // * fourth argument to require.context (sync) is ignored/not supported 8 | 9 | const acorn = require("acorn"); 10 | const stage3 = require("acorn-stage3"); 11 | const walk = require("acorn-walk"); 12 | const astring = require("astring"); 13 | const path = require('path'); 14 | const fsp = require('fs').promises; 15 | 16 | let dirWatchers = {}; 17 | 18 | let parser = acorn.Parser.extend(stage3); 19 | 20 | // Replace call to require.context with an object literal, prepending import 21 | // import statements as required. If no require.context calls are found, 22 | // null will be returned, allowing the code to pass on to the next plugin. 23 | // See: https://www.snowpack.dev/guides/plugins#tips-%2F-gotchas 24 | async function require_context(filePath) { 25 | let source = await fsp.readFile(filePath, 'utf8'); 26 | 27 | // If there are no occurences of "require.context(", remove source from 28 | // dirWatchers and return null 29 | if (!/\brequire\s*\.\s*context\s*\(/.test(source)) { 30 | delete dirWatchers[filePath]; 31 | return null; 32 | } 33 | 34 | let ast = parser.parse(source, 35 | { sourceType: 'module', ecmaVersion: 'latest', locations: true }); 36 | 37 | // determine base directory 38 | let base = path.dirname(path.resolve(filePath)); 39 | 40 | // find all context.require calls in this AST 41 | let nodes = []; 42 | walk.simple(ast, { 43 | CallExpression(node) { 44 | // match context.require(Literal...) calls 45 | let { callee } = node; 46 | let args = node.arguments; // curse you, strict mode! 47 | if (callee.type !== 'MemberExpression') return; 48 | if (callee.object.name !== 'require') return; 49 | if (callee.property.name !== 'context') return; 50 | if (args.length === 0) return; 51 | if (!args.every(arg => arg.type === 'Literal')) return; 52 | if (args.length > 2 && !args[2].regex) return; 53 | nodes.push(node); 54 | } 55 | }); 56 | 57 | // If none found, remove source from dirWatchers and return null 58 | if (nodes.length === 0) { 59 | delete dirWatchers[filePath]; 60 | return null; 61 | } 62 | 63 | let imports = []; // list of imports to be prepended 64 | let dirs = []; // list of directories to be watched for changes 65 | 66 | await Promise.all(nodes.map(async node => { 67 | // extract arguments 68 | let args = node.arguments; 69 | let directory = path.resolve(base, args[0].value); 70 | let recurse = args[1] && args[1].value; 71 | let regExp = args[2] && new RegExp(args[2].regex.pattern, args[2].regex.flags); 72 | 73 | // add directory to the list to be watched for changes 74 | dirs.push(directory); 75 | 76 | // get a list of files in a given directory matching a given pattern, 77 | // optionally recursively. 78 | async function getFiles(dir, recurse, pattern) { 79 | try { 80 | const dirents = await fsp.readdir(dir, { withFileTypes: true }); 81 | const files = await Promise.all(dirents.map(dirent => { 82 | const res = path.resolve(dir, dirent.name); 83 | 84 | if (dirent.isDirectory()) { 85 | return (recurse !== false) && getFiles(res, recurse, pattern); 86 | } else { 87 | return (!pattern || pattern.test(res)) ? res : null 88 | } 89 | })); 90 | 91 | return Array.prototype.concat(...files); 92 | } catch (error) { 93 | if (error.code === 'ENOENT') return []; 94 | throw error; 95 | }; 96 | } 97 | 98 | // get a list of files, remove nulls, make file names relative to base 99 | let files = (await getFiles(directory, recurse, regExp)). 100 | filter(file => file).map(file => { 101 | file = path.relative(base, file); 102 | if (!file.startsWith('/') && !file.startsWith('.')) file = `./${file}`; 103 | return file 104 | }); 105 | 106 | // keys are relative to the directory, not the base 107 | let keys = files.map(file => 108 | path.relative(directory, path.resolve(base, file))); 109 | 110 | // compute module name from keys converting from dashes to snakecase. 111 | let modules = keys.map(key => { 112 | // remove extension 113 | let parts = key.split('/'); 114 | parts.push(parts.pop().split('.')[0]); 115 | 116 | // convert to snakecase, replacing '/' with '_' 117 | return parts.map(part => part. 118 | replace(/^\w/, c => c.toUpperCase()). 119 | replace(/[-_]\w/g, c => c[1].toUpperCase()).replace(/\W/g, '$')) 120 | .join('_'); 121 | }); 122 | 123 | // add an import for each module to the list of prepends 124 | files.forEach((file, i) => { 125 | imports.push({ 126 | type: "ImportDeclaration", 127 | specifiers: [ 128 | { 129 | type: "ImportDefaultSpecifier", 130 | local: { type: "Identifier", name: modules[i] } 131 | } 132 | ], 133 | source: { type: "Literal", value: file, raw: JSON.stringify(file) } 134 | }) 135 | }); 136 | 137 | // build a list of files 138 | let contextKeys = { 139 | type: "ArrayExpression", 140 | elements: keys.map(file => ( 141 | { type: "Literal", value: file, raw: JSON.stringify(file) } 142 | )) 143 | }; 144 | 145 | // build a map of files to {default: modules} object literals 146 | let contextMap = { 147 | type: "ObjectExpression", 148 | properties: keys.map((key, i) => ({ 149 | type: "Property", 150 | method: false, 151 | shorthand: false, 152 | computed: false, 153 | key: { type: "Literal", value: key, raw: JSON.stringify(key) }, 154 | value: { 155 | type: "ObjectExpression", 156 | properties: [{ 157 | type: "Property", 158 | method: false, 159 | shorthand: false, 160 | computed: false, 161 | key: { type: "Identifier", name: "default" }, 162 | value: { type: "Identifier", name: modules[i] }, 163 | kind: "init" 164 | }] 165 | }, 166 | kind: "init" 167 | })) 168 | }; 169 | 170 | let contextFn = { 171 | type: "VariableDeclaration", 172 | declarations: [ 173 | { 174 | type: "VariableDeclarator", 175 | id: { type: "Identifier", name: "context" }, 176 | init: { 177 | type: "ArrowFunctionExpression", 178 | id: null, 179 | expression: true, 180 | generator: false, 181 | async: false, 182 | params: [{ type: "Identifier", name: "id" }], 183 | body: { 184 | type: "MemberExpression", 185 | object: contextMap, 186 | property: { type: "Identifier", name: "id" }, 187 | computed: true, 188 | optional: false 189 | } 190 | } 191 | } 192 | ], 193 | kind: "let" 194 | }; 195 | 196 | let keyFn = { 197 | type: "ExpressionStatement", 198 | expression: { 199 | type: "AssignmentExpression", 200 | operator: "=", 201 | left: { 202 | type: "MemberExpression", 203 | object: { type: "Identifier", name: "context" }, 204 | property: { type: "Identifier", name: "keys" }, 205 | computed: false, 206 | optional: false 207 | }, 208 | right: { 209 | type: "ArrowFunctionExpression", 210 | id: null, 211 | expression: true, 212 | generator: false, 213 | async: false, 214 | params: [], 215 | body: contextKeys 216 | } 217 | } 218 | }; 219 | 220 | let contextExpr = { 221 | type: "CallExpression", 222 | callee: { 223 | type: "ArrowFunctionExpression", 224 | id: null, 225 | expression: false, 226 | generator: false, 227 | async: false, 228 | params: [], 229 | body: { 230 | type: "BlockStatement", 231 | body: [ 232 | contextFn, 233 | keyFn, 234 | { 235 | type: "ReturnStatement", 236 | argument: { 237 | type: "Identifier", 238 | name: "context" 239 | } 240 | } 241 | ] 242 | } 243 | }, 244 | arguments: [], 245 | optional: false 246 | }; 247 | 248 | // remove content from node (leaving source loc info) 249 | delete node.callee; 250 | delete node.arguments; 251 | delete node.optional; 252 | 253 | // replace node with expression building up a context object 254 | Object.assign(node, contextExpr); 255 | })); 256 | 257 | // prepend import statements 258 | ast.body.unshift(...imports) 259 | 260 | // update the list of directories to be watched 261 | dirWatchers[filePath] = dirs; 262 | 263 | // regenerate source from updated AST. 264 | // TODO: (optional?) sourceMaps 265 | return astring.generate(ast); 266 | } 267 | 268 | // plugin 269 | module.exports = function (snowpackConfig, pluginOptions) { 270 | return { 271 | name: 'require-context-plugin', 272 | 273 | // default to processing all .js files (into .js). Enable 274 | // pluginOptions.input to override which files are to be processed. 275 | resolve: { 276 | input: Array.from(pluginOptions.input || ['.js']), 277 | output: ['.js'], 278 | }, 279 | 280 | // If a change happens in a watched directory, mark the source referencing 281 | // that directory as changed. 282 | onChange({ filePath }) { 283 | for (const [source, dirs] of Object.entries(dirWatchers)) { 284 | if (dirs.some(dir => filePath.startsWith(dir))) { 285 | this.markChanged(source) 286 | } 287 | } 288 | }, 289 | 290 | // Load hook: invoke require_context on each file matched 291 | async load({ filePath }) { 292 | return await require_context(filePath); 293 | } 294 | } 295 | }; 296 | --------------------------------------------------------------------------------