├── CHANGELOG.md ├── LICENSE.md ├── README.md ├── bin └── importmap ├── composer.json ├── config └── importmap.php ├── rector.php ├── resources └── views │ ├── .gitkeep │ └── components │ └── tags.blade.php ├── src ├── Actions │ ├── FixJsImportPaths.php │ └── ReplaceOrAppendTags.php ├── AssetResolver.php ├── Commands │ ├── AuditCommand.php │ ├── ClearCacheCommand.php │ ├── InstallCommand.php │ ├── JsonCommand.php │ ├── OptimizeCommand.php │ ├── OutdatedCommand.php │ ├── PackagesCommand.php │ ├── PinCommand.php │ ├── UnpinCommand.php │ └── UpdateCommand.php ├── Events │ └── FailedToFixImportStatement.php ├── Exceptions │ ├── FailedToFixImportStatementException.php │ └── ImportmapException.php ├── Facades │ └── Importmap.php ├── FileDigest.php ├── Importmap.php ├── ImportmapLaravelServiceProvider.php ├── Manifest.php ├── MappedDirectory.php ├── MappedFile.php ├── Npm.php ├── OutdatedPackage.php ├── PackageVersion.php ├── Packager.php └── VulnerablePackage.php └── stubs ├── js └── app.js ├── jsconfig.json └── routes └── importmap.php /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to `importmap-laravel` will be documented in this file. 4 | 5 | ## 2.4.0 - 2025-03-02 6 | 7 | ### What's Changed 8 | 9 | * Laravel 12.x Compatibility by @laravel-shift in https://github.com/tonysm/importmap-laravel/pull/59 10 | 11 | **Full Changelog**: https://github.com/tonysm/importmap-laravel/compare/2.3.2...2.4.0 12 | 13 | ## 2.3.1 - 2024-03-13 14 | 15 | ### What's Changed 16 | 17 | - Skip adding the `` component to layouts if they already exist in https://github.com/tonysm/importmap-laravel/commit/ff2019eb14b48223c985e6cdee0601455bc41d88 18 | 19 | **Full Changelog**: https://github.com/tonysm/importmap-laravel/compare/2.3.0...2.3.1 20 | 21 | ## 2.3.0 - 2024-03-13 22 | 23 | ### What's Changed 24 | 25 | - Skip axios installation with a warning by @tonysm in https://github.com/tonysm/importmap-laravel/commit/c77bb163d9a4b2f81d0d399b7c03323e9562b91a 26 | 27 | **Full Changelog**: https://github.com/tonysm/importmap-laravel/compare/2.2.0...2.3.0 28 | 29 | ## 2.2.0 - 2024-03-06 30 | 31 | ### What's Changed 32 | 33 | * Laravel 11 Support by @tonysm in https://github.com/tonysm/importmap-laravel/pull/54 34 | 35 | **Full Changelog**: https://github.com/tonysm/importmap-laravel/compare/2.1.4...2.2.0 36 | 37 | ## 2.1.2 - 2024-02-23 38 | 39 | ### What's Changed 40 | 41 | * Read typo fixes by @emaia in https://github.com/tonysm/importmap-laravel/pull/52 42 | * Update the install command to either replace the vite directive or append the importmap tags before the closing head tag by @tonysm in https://github.com/tonysm/importmap-laravel/commit/ce304706a698b35aa46ef3168d6bf2db8ee2a97d 43 | 44 | ### New Contributors 45 | 46 | * @emaia made their first contribution in https://github.com/tonysm/importmap-laravel/pull/52 47 | 48 | **Full Changelog**: https://github.com/tonysm/importmap-laravel/compare/2.0.3...2.1.2 49 | 50 | ## 2.1.1 - 2024-02-05 51 | 52 | ### What's Changed 53 | 54 | * Fix outdated command breaks on previous comment format by @tonysm in https://github.com/tonysm/importmap-laravel/pull/50 55 | 56 | **Full Changelog**: https://github.com/tonysm/importmap-laravel/compare/2.1.0...2.1.1 57 | 58 | ## 2.1.0 - 2024-02-04 59 | 60 | ### What's Changed 61 | 62 | * New `importmap:update` command and store CDN URL with package name and version in vendor comment by @tonysm in https://github.com/tonysm/importmap-laravel/pull/49 63 | 64 | **Full Changelog**: https://github.com/tonysm/importmap-laravel/compare/2.0.1...2.1.0 65 | 66 | ## 2.0.1 - 2024-02-02 67 | 68 | ### What's Changed 69 | 70 | * Fix: Prevent Type Error: Unsupported operand types: null + array by @JunaidQadirB in https://github.com/tonysm/importmap-laravel/pull/48 71 | * Fix: Optimize command wasn't working when specifying public vendor lib starting with a forward slash (`/vendor/my-lib.js`, for instance) https://github.com/tonysm/importmap-laravel/commit/4e17a78ba5c0b802e9825806fbafc7086461a670 72 | * Fix: Digest wasn't being applied on public vendor libs when starting with a forward slash either https://github.com/tonysm/importmap-laravel/commit/139b788c9837d7313c96e62b8cada68082ea160a 73 | 74 | ### New Contributors 75 | 76 | * @JunaidQadirB made their first contribution in https://github.com/tonysm/importmap-laravel/pull/48 77 | 78 | **Full Changelog**: https://github.com/tonysm/importmap-laravel/compare/2.0.0...2.0.1 79 | 80 | ## 2.0.0 - 2024-01-07 81 | 82 | ### What's Changed 83 | 84 | * Drops the shim by @tonysm in https://github.com/tonysm/importmap-laravel/pull/43 85 | * Preload default by @tonysm in https://github.com/tonysm/importmap-laravel/pull/44 86 | * Always download dependencies by @tonysm in https://github.com/tonysm/importmap-laravel/pull/45 87 | * Dont optimize with URL by @tonysm in https://github.com/tonysm/importmap-laravel/pull/46 88 | * Install with default jsconfig.json by @tonysm in https://github.com/tonysm/importmap-laravel/pull/47 89 | 90 | **Full Changelog**: https://github.com/tonysm/importmap-laravel/compare/1.8.3...2.0.0 91 | 92 | 93 | --- 94 | 95 | ### Upgrade Guide 96 | 97 | The pinned imports to CDN URLs will still work, but I recommend you re-pin them now and host them yourself (we're now always downloading vendor libs to `resources/js/vendor/`). 98 | 99 | The `` component has changed to `` so you can run the following command to replace all occurrences of the previous component name in your layout files: 100 | 101 | ```bash 102 | sed -i 's/x-importmap-tags/x-importmap::tags/g' resources/**/*.php 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | ``` 113 | Also, ensure you have `php artisan view:cache` in your deployment script. 114 | 115 | ## 1.8.1 - 2023-11-24 116 | 117 | ### Changelog 118 | 119 | - **FIX**: Fixes the failed to fix import statement event name (https://github.com/tonysm/importmap-laravel/commit/d32c41d3d38ed27767ce8868af0a8861727196ea) 120 | 121 | ## 1.8.0 - 2023-11-13 122 | 123 | ### What's Changed 124 | 125 | - Fix installation script not properly fixing paths resolution by @tonysm in https://github.com/tonysm/importmap-laravel/pull/34 126 | - Bump shims version to 1.8.2 by @tonysm in https://github.com/tonysm/importmap-laravel/pull/35 127 | 128 | **Full Changelog**: https://github.com/tonysm/importmap-laravel/compare/1.7.0...1.8.0 129 | 130 | ## 1.7.0 - 2023-11-12 131 | 132 | ### What's Changed 133 | 134 | - Adds a `bin/importmap` script by @tonysm in https://github.com/tonysm/importmap-laravel/pull/33 135 | 136 | **Full Changelog**: https://github.com/tonysm/importmap-laravel/compare/1.6.0...1.7.0 137 | 138 | ## 1.6.0 - 2023-07-27 139 | 140 | ### Changelog 141 | 142 | - **CHANGED**: Push symlinks config to package instead of patching the application's `config/filesystems.php` file by @tonysm in https://github.com/tonysm/importmap-laravel/pull/29 143 | 144 | **Full Changelog**: https://github.com/tonysm/importmap-laravel/compare/1.5.0...1.6.0 145 | 146 | ## 1.5.0 - 2023-07-14 147 | 148 | ### Changelog 149 | 150 | - **NEW**: New `importmap:packages` command that lists out the external packages being imported 151 | - **FIXED**: Fixes single quotes support in the `routes/importmap.php` file 152 | 153 | **Full Changelog**: https://github.com/tonysm/importmap-laravel/compare/1.4.1...1.5.0 154 | 155 | ## 1.4.1 - 2023-05-10 156 | 157 | ### What's Changed 158 | 159 | - Bump the shim version to 1.7.2 160 | 161 | **Full Changelog**: https://github.com/tonysm/importmap-laravel/compare/1.4.0...1.4.1 162 | 163 | ## 1.4.0 - 2023-02-14 164 | 165 | ### Changelog 166 | 167 | - **CHANGED**: Bumps the default `es-module-shims` version to `1.3.1` 168 | - **CHANGED**: Support Laravel 10 169 | 170 | ## 1.3.1 - 2023-02-14 171 | 172 | ### Changelog 173 | 174 | - **CHANGED**: Bumps the default `es-module-shims` version to `1.3.1` 175 | - **CHANGED**: Support Laravel 10 176 | 177 | ## 1.3.0 - 2022-12-28 178 | 179 | ### Changelog 180 | 181 | - **CHANGED**: Bumped `es-module-shims` version to `1.6.2` (latest) and make it configurable so applications may bump it without having to upgrade the package 182 | 183 | ## 1.2.3 - 2022-08-04 184 | 185 | ### Changelog 186 | 187 | - **FIXED**: Fixes the optimize command when pinning dependencies from `public/vendor` (https://github.com/tonysm/importmap-laravel/commit/a3a685583bfaaf82e737f0ec2fb368f63f3d3c1f) 188 | 189 | ## 1.2.2 - 2022-08-04 190 | 191 | ### Changelog 192 | 193 | - **CHANGED**: stop escapeing the slashes in the `importmap:json` output (https://github.com/tonysm/importmap-laravel/commit/496cb8bc77c51fd1dae28f12e37a881b4cc41997) 194 | - **NEW**: handle imported files from `public/vendor` folder (https://github.com/tonysm/importmap-laravel/commit/b6c22d1f047715b1f47393dc55a59730397aa55a) 195 | 196 | ## 1.2.1 - 2022-07-29 197 | 198 | ### Changelog 199 | 200 | - **CHANGED**: we don't delete the `public/js` folder anymore, but instead ask the developer to do so (https://github.com/tonysm/importmap-laravel/commit/f0b3ad562bb748fe20f34768d8b9fb49936099c7) 201 | 202 | ## 1.2.0 - 2022-07-03 203 | 204 | ### Changelog 205 | 206 | - **CHANGED**: The `importmap:install` command was changed to work with the new Vite setup in Laravel. It should also still work on installs in the Laravel 8 frontends setups using Mix. 207 | 208 | ## 1.1.1 - 2022-06-30 209 | 210 | ### Changelog 211 | 212 | - **FIXED**: The `importmap:pin` command was breaking depending on the package name because we needed to wrap the package name using the `preg_quote` to escape it. Otherwise, some characters might become part of the regex itself. https://github.com/tonysm/importmap-laravel/pull/16 213 | 214 | ## 1.1.0 - 2022-06-27 215 | 216 | ### Changelog 217 | 218 | - Bumps `es-module-shims` to version 1.5.8 219 | 220 | ## 0.4.1 - 2022-02-13 221 | 222 | ### Changelog 223 | 224 | - **FIXED**: Pinned directories were not working on Windows because we're using `/` instead of `\`. Anyways, that should be fixed now. Define the directories with `/` as you would on any Unix/Linux OS and the package will make sure that gets converted to the correct directory separator when dealing with file paths and to the `/` separator when dealing with URIs https://github.com/tonysm/importmap-laravel/pull/5 225 | 226 | ## 0.4.0 - 2022-02-13 227 | 228 | ### Changelog 229 | 230 | - **CHANGED**: Changes the manifest filename to be `.importmap-manifest.json` (with a dot prefix) so it can be included in the Vapor artifact (which doesn't remove dotfiles by default). 231 | 232 | ## 0.3.0 - 2022-02-09 233 | 234 | ### Changelog 235 | 236 | - **CHANGED**: Laravel 9 support (nothing really changed in the app, just the version constraints) 237 | 238 | ## 0.2.0 - 2022-01-27 239 | 240 | ### Changed 241 | 242 | - **FIXED**: The manifest already had the final asset URL on it, which is handled by the optimize command, so we don't need to call the asset resolver when the manifest exists 243 | - **NEW**: Added an `AssetResolver` invokable class which should add a `?digest=$HASH` to the asset URL, which is useful for cache busting while in local development. This won't be used in production as the optimize command already generates the full URLs there, which means the `AssetResolver` won't be called 244 | - **CHANGED**: The `entrypoint` was made optional and it defaults to the `app` module, which matches the "entrypoint" file in the default Laravel install (`resources/js/app.js`) 245 | 246 | ## 1.0.0 - 202X-XX-XX 247 | 248 | - initial release 249 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) tonysm 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 13 | all 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 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

Logo Importmap Laravel

2 | 3 |

4 | 5 | Total Downloads 6 | 7 | 8 | Latest Stable Version 9 | 10 | 11 | License 12 | 13 |

14 | 15 | ## Introduction 16 | 17 | Use ESM with importmap to manage modern JavaScript in Laravel without transpiling or bundling. 18 | 19 | ### Inspiration 20 | 21 | This package was inspired by the [Importmap Rails](https://github.com/rails/importmap-rails) gem. Some pieces of this README were copied straight from there and adapted to the Laravel version. 22 | 23 | ### How does it work? 24 | 25 | [Import maps](https://github.com/WICG/import-maps) lets you import JavaScript modules directly from the browser using logical names that map to versioned/digested files. So you can [build modern JavaScript applications using JavaScript libraries made for ES modules (ESM) without the need for transpiling or bundling](https://world.hey.com/dhh/modern-web-apps-without-javascript-bundling-or-transpiling-a20f2755). This frees you from needing Webpack, Yarn, npm, or any other part of the JavaScript toolchain. 26 | 27 | With this approach, you'll ship many small JavaScript files instead of one big JavaScript file. Thanks to HTTP/2 that no longer carries a material performance penalty during the initial transport, and offers substantial benefits over the long run due to better caching dynamics. Whereas before any change to any JavaScript file included in your big bundle would invalidate the cache for the whole bundle, now only the cache for that single file is invalidated. 28 | 29 | [Import maps are supported natively in all major, modern browsers](https://caniuse.com/?search=importmap). If you need to work with legacy browsers without native support, you may want to explore using [the shim available](https://github.com/guybedford/es-module-shims). 30 | 31 | ## Installation 32 | 33 | You can install the package via Composer: 34 | 35 | ```bash 36 | composer require tonysm/importmap-laravel 37 | ``` 38 | 39 | The package has an `install` command that you may run to replace the default Laravel scaffold with one to use importmap: 40 | 41 | ```bash 42 | php artisan importmap:install 43 | ``` 44 | 45 | Next, we need to add the following component to our view or layout file: 46 | 47 | ```blade 48 | 49 | ``` 50 | 51 | Add that between your `` tags. The `entrypoint` should be the "main" file, commonly the `resources/js/app.js` file, which will be mapped to the `app` module (use the module name, not the file). 52 | 53 | By default, the `x-importmap::tags` component assumes your entrypoint module is `app`, which matches the existing `resources/js/app.js` file from Laravel's default scaffolding. You may want to customize the entrypoint, which you can do with the `entrypoint` prop: 54 | 55 | ```blade 56 | 57 | ``` 58 | 59 | The package will automatically map the `resources/js` folder to your `public/js` folder using Laravel's symlink feature. All you have to do after installing the package is run: 60 | 61 | ```bash 62 | php artisan storage:link 63 | ``` 64 | 65 | If you're using Laravel Sail, make sure you prefix that command with `sail` as the symlink needs to be created inside the container. 66 | 67 | The symlink is only registered in local environments. For production, it's recommended to run the `importmap:optimize` command instead: 68 | 69 | ```php 70 | php artisan importmap:optimize 71 | ``` 72 | 73 | This should scan all your pinned files/folders (no URLs) and publish them to `public/dist/js`, adding a digest based on the file's content to the file name - so something like `public/dist/js/app-123123.js`, and then generate a `.importmap-manifest.json` file in the `public/` folder. This file will get precedence over your pins. If you run that by accident in development, manually delete that file or run `php artisan importmap:clear`, which should delete it for you. You may also want to add the `/public/dist` path and the `*importmap-manifest.json` file to your `.gitignore` file. 74 | 75 | ## Usage 76 | 77 | In a nutshell, importmap works by giving the browser a map of where to look for your JavaScript import statements. For instance, you could _pin_ a dependency in the `routes/importmap.php` file for Alpinejs like so: 78 | 79 | ```php 80 | 175 | ``` 176 | 177 | ## Dependency Maintenance Commands 178 | 179 | Maintaining a healthy dependency list can be tricky. Here are a couple of commands to help you with this task. 180 | 181 | ### Outdated Dependencies 182 | 183 | To keep your dependencies up-to-date, make sure you run the `importmap:outdated` command from time to time: 184 | 185 | ```bash 186 | php artisan importmap:outdated 187 | ``` 188 | 189 | This command will scan your `routes/importmap.php` file, find your current versions, and then use the NPM registry API to look for the latest version of the packages you're using. It also handles locally served vendor libs that you added using the `--download` flag from the `importmap:pin` command. 190 | 191 | ### Auditing Dependencies 192 | 193 | If you want a security audit on your dependencies to see if you're using a version that's been breached, run the `importmap:audit` command from time to time. Better yet, add that command to your CI build: 194 | 195 | ```bash 196 | php artisan importmap:audit 197 | ``` 198 | 199 | This will also scan your `routes/importmap.php` file, find your current versions, and then use the NPM registry API to look for vulnerabilities in your packages. It also handles locally served vendor libs that you added using the `--download` flag from the `importmap:pin` command. 200 | 201 | ## Known Problems 202 | 203 | ### On React's JSX and Vue's SFC 204 | 205 | It's possible to use both React and Vue with importmaps but, unfortunately, you would have to use those without the power of JSX or SFC. That's because those file types need a compilation/transpilation step where they are converted to something the browser can understand. There are alternative ways to use both these libraries, but I should say that these are not "common" ways in their communities. You may use [React with HTM](https://github.com/developit/htm). And you can use Vue just fine without SFC, the only difference is that your templates would be in Blade files, not a SFC file. 206 | 207 | ### Process ENV Configs 208 | 209 | You may be used to having a couple `process.env.MIX_*` lines in your JS files here and there. The way this works is Webpack would replace at build time of your calls to `process.env` with the values it had during the build. Since we don't have a "build time" anymore, this won't work. Instead, you should add `` tags to your layout file with anything that you want to make available to your JavaScript files and use `document.head.querySelector('meta[name=my-config]').content` instead of relying on the `process.env`. 210 | 211 | Consider using something like [`current.js`](https://www.npmjs.com/package/current.js) to easily consume your `` configs using a globally available `Current` object. 212 | 213 | ## Testing 214 | 215 | ```bash 216 | composer test 217 | ``` 218 | 219 | ## Changelog 220 | 221 | Please see [CHANGELOG](CHANGELOG.md) for more information on what has changed recently. 222 | 223 | ## Contributing 224 | 225 | Please see [CONTRIBUTING](.github/CONTRIBUTING.md) for details. 226 | 227 | ## Security Vulnerabilities 228 | 229 | Please review [our security policy](../../security/policy) on how to report security vulnerabilities. 230 | 231 | ## Credits 232 | 233 | - [Tony Messias](https://github.com/tonysm) 234 | - [All Contributors](../../contributors) 235 | 236 | ## License 237 | 238 | The MIT License (MIT). Please see [License File](LICENSE.md) for more information. 239 | -------------------------------------------------------------------------------- /bin/importmap: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | ARGS=(php artisan) 4 | 5 | if [ $# -gt 0 ]; then 6 | ARGS+=("importmap:$@") 7 | else 8 | ARGS+=(list importmap) 9 | fi 10 | 11 | OUTPUT=$("${ARGS[@]}") 12 | MODIFIED_OUTPUT="${OUTPUT//importmap:/importmap }" 13 | 14 | echo "${MODIFIED_OUTPUT}" 15 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tonysm/importmap-laravel", 3 | "description": "Use ESM with importmap to manage modern JavaScript in Laravel without transpiling or bundling.", 4 | "keywords": [ 5 | "tonysm", 6 | "laravel", 7 | "importmap-laravel" 8 | ], 9 | "homepage": "https://github.com/tonysm/importmap-laravel", 10 | "license": "MIT", 11 | "authors": [ 12 | { 13 | "name": "Tony Messias", 14 | "email": "tonysm@hey.com", 15 | "role": "Developer" 16 | } 17 | ], 18 | "require": { 19 | "php": "^8.2", 20 | "illuminate/contracts": "^11.0|^12.0", 21 | "illuminate/support": "^11.0|^12.0", 22 | "spatie/laravel-package-tools": "^1.9" 23 | }, 24 | "require-dev": { 25 | "guzzlehttp/guzzle": "^7.4", 26 | "laravel/pint": "^1.10", 27 | "orchestra/testbench": "^9.0|^10.0", 28 | "phpstan/extension-installer": "^1.1", 29 | "phpstan/phpstan-deprecation-rules": "^1.0|^2.0", 30 | "phpstan/phpstan-phpunit": "^1.0|^2.0", 31 | "phpunit/phpunit": "^10.5|^11.5.3" 32 | }, 33 | "bin": [ 34 | "bin/importmap" 35 | ], 36 | "autoload": { 37 | "psr-4": { 38 | "Tonysm\\ImportmapLaravel\\": "src" 39 | } 40 | }, 41 | "autoload-dev": { 42 | "psr-4": { 43 | "Tonysm\\ImportmapLaravel\\Tests\\": "tests" 44 | } 45 | }, 46 | "scripts": { 47 | "analyse": "vendor/bin/phpstan analyse", 48 | "test": "vendor/bin/pest", 49 | "test-coverage": "vendor/bin/pest --coverage" 50 | }, 51 | "config": { 52 | "sort-packages": true, 53 | "allow-plugins": { 54 | "pestphp/pest-plugin": true, 55 | "phpstan/extension-installer": true 56 | } 57 | }, 58 | "extra": { 59 | "laravel": { 60 | "providers": [ 61 | "Tonysm\\ImportmapLaravel\\ImportmapLaravelServiceProvider" 62 | ], 63 | "aliases": { 64 | "Importmap": "Tonysm\\ImportmapLaravel\\Facades\\Importmap" 65 | } 66 | } 67 | }, 68 | "minimum-stability": "dev", 69 | "prefer-stable": true 70 | } 71 | -------------------------------------------------------------------------------- /config/importmap.php: -------------------------------------------------------------------------------- 1 | public_path('.importmap-manifest.json'), 15 | ]; 16 | -------------------------------------------------------------------------------- /rector.php: -------------------------------------------------------------------------------- 1 | withPaths([ 10 | __DIR__.'/src', 11 | __DIR__.'/tests', 12 | ]) 13 | ->withPreparedSets( 14 | deadCode: true, 15 | codeQuality: true, 16 | typeDeclarations: true, 17 | privatization: true, 18 | earlyReturn: true, 19 | ) 20 | ->withAttributesSets() 21 | ->withPhpSets() 22 | ->withPhpVersion(PhpVersion::PHP_82); 23 | -------------------------------------------------------------------------------- /resources/views/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tonysm/importmap-laravel/a6ab9dcf6693d4b2c70762be73642cff0de30e4c/resources/views/.gitkeep -------------------------------------------------------------------------------- /resources/views/components/tags.blade.php: -------------------------------------------------------------------------------- 1 | @props(['entrypoint' => 'app', 'nonce' => null, 'importmap' => null]) 2 | 3 | @php 4 | $resolver = new \Tonysm\ImportmapLaravel\AssetResolver(); 5 | 6 | $importmaps = $importmap?->asArray($resolver) ?? \Tonysm\ImportmapLaravel\Facades\Importmap::asArray($resolver); 7 | $preloadedModules = $importmap?->preloadedModulePaths($resolver) ?? \Tonysm\ImportmapLaravel\Facades\Importmap::preloadedModulePaths($resolver); 8 | @endphp 9 | 10 | 13 | 14 | @foreach ($preloadedModules as $preloadedModule) 15 | 16 | @endforeach 17 | 18 | 19 | -------------------------------------------------------------------------------- /src/Actions/FixJsImportPaths.php: -------------------------------------------------------------------------------- 1 | output ??= $root; 16 | } 17 | 18 | public function __invoke(): void 19 | { 20 | collect(File::allFiles($this->root)) 21 | ->filter(fn (SplFileInfo $file): bool => in_array($file->getExtension(), ['js', 'mjs'])) 22 | ->each(fn (SplFileInfo $file) => File::ensureDirectoryExists($this->absoluteOutputPathFor($file))) 23 | ->each(fn (SplFileInfo $file) => File::put( 24 | $this->absoluteOutputPathWithFileFor($file), 25 | $this->updatedJsImports($file), 26 | )); 27 | } 28 | 29 | private function absoluteOutputPathFor(SplFileInfo $file): string 30 | { 31 | return str_replace($this->root, $this->output, dirname($file->getRealPath())); 32 | } 33 | 34 | private function absoluteOutputPathWithFileFor(SplFileInfo $file): string 35 | { 36 | return rtrim((string) $this->absoluteOutputPathFor($file), DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR.$file->getFilename(); 37 | } 38 | 39 | private function updatedJsImports(SplFileInfo $file): string 40 | { 41 | $lines = File::lines($file->getRealPath())->all(); 42 | 43 | foreach ($lines as $index => $line) { 44 | if (! str_starts_with((string) $line, 'import ')) { 45 | continue; 46 | } 47 | 48 | try { 49 | $lines[$index] = preg_replace_callback( 50 | '#import.+["\']([\.]+.*)["\']#', 51 | function ($matches) use ($file): string { 52 | $replaced = $this->replaceDotImports($file, $matches[1], $matches[0]); 53 | 54 | $relative = trim(str_replace($this->root, '', $replaced), DIRECTORY_SEPARATOR); 55 | 56 | return str_replace(DIRECTORY_SEPARATOR, '/', str_replace($matches[1], $relative, $matches[0])); 57 | }, 58 | (string) $line, 59 | ); 60 | } catch (FailedToFixImportStatementException $exception) { 61 | event(new FailedToFixImportStatement($exception->file, $exception->importStatement)); 62 | } 63 | } 64 | 65 | return implode(PHP_EOL, $lines); 66 | } 67 | 68 | private function replaceDotImports(SplFileInfo $file, string $imports, string $line) 69 | { 70 | $removeExtension = false; 71 | $removeIndex = false; 72 | $path = rtrim($file->getPath(), DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $imports); 73 | 74 | if (is_dir($path)) { 75 | $removeIndex = true; 76 | $path = File::exists(implode(DIRECTORY_SEPARATOR, [rtrim($path, DIRECTORY_SEPARATOR), 'index.mjs'])) 77 | ? rtrim($path, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR.'index.mjs' 78 | : rtrim($path, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR.'index.js'; 79 | } 80 | 81 | if (! str_ends_with($path, '.js') && ! str_ends_with($path, '.mjs')) { 82 | $removeExtension = true; 83 | $path = File::exists($path.'.mjs') 84 | ? $path.'.mjs' 85 | : $path.'.js'; 86 | } 87 | 88 | if (($fixedPath = realpath($path)) === false) { 89 | throw FailedToFixImportStatementException::couldNotFixImport($line, $file); 90 | } 91 | 92 | if ($removeIndex) { 93 | return Str::beforeLast($fixedPath, DIRECTORY_SEPARATOR); 94 | } 95 | 96 | if ($removeExtension) { 97 | return Str::beforeLast($fixedPath, '.'); 98 | } 99 | 100 | return $fixedPath; 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /src/Actions/ReplaceOrAppendTags.php: -------------------------------------------------------------------------------- 1 | )/'; 10 | 11 | public function __invoke(string $contents) 12 | { 13 | if (str_contains($contents, '')) { 14 | return $contents; 15 | } 16 | 17 | if (str_contains($contents, '@vite')) { 18 | return preg_replace( 19 | static::VITE_DIRECTIVE_PATTERN, 20 | '\\1', 21 | $contents, 22 | ); 23 | } 24 | 25 | return preg_replace( 26 | static::CLOSING_HEAD_TAG_PATTERN, 27 | PHP_EOL.'\\1 \\1\\2', 28 | $contents, 29 | ); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/AssetResolver.php: -------------------------------------------------------------------------------- 1 | vulnerablePackages(); 21 | 22 | if ($vulnerablePackages->isEmpty()) { 23 | $this->info('No vulnerable packages found.'); 24 | 25 | return self::SUCCESS; 26 | } 27 | 28 | $this->table( 29 | ['Package', 'Severity', 'Vulnerable Versions', 'Vulnerability'], 30 | $vulnerablePackages 31 | ->map(fn (VulnerablePackage $package): array => [$package->name, $package->severity, $package->vulnerableVersions, $package->vulnerability]) 32 | ->all() 33 | ); 34 | 35 | $this->newLine(); 36 | 37 | $summary = $vulnerablePackages 38 | ->groupBy('severity') 39 | ->map(fn ($vulns): int => $vulns->count()) 40 | ->sortDesc() 41 | ->map(fn ($count, $severity): string => "$count {$severity}") 42 | ->join(', '); 43 | 44 | $this->error(sprintf( 45 | '%d %s found: %s', 46 | $vulnerablePackages->count(), 47 | Str::plural('vulnerability', $vulnerablePackages->count()), 48 | $summary, 49 | )); 50 | 51 | return self::FAILURE; 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/Commands/ClearCacheCommand.php: -------------------------------------------------------------------------------- 1 | info('Clearing cached manifest...'); 21 | 22 | if (File::exists($manifest = Manifest::path())) { 23 | File::delete($manifest); 24 | } 25 | 26 | $this->info('Manifest file cleared!'); 27 | 28 | return self::SUCCESS; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/Commands/InstallCommand.php: -------------------------------------------------------------------------------- 1 | convertLocalImportsFromUsingDots(); 26 | $this->publishImportmapFiles(); 27 | $this->importDependenciesFromNpm(); 28 | $this->updateAppLayouts(); 29 | $this->deleteNpmRelatedFiles(); 30 | $this->configureIgnoredFolder(); 31 | $this->runStorageLinkCommand(); 32 | 33 | $this->newLine(); 34 | $this->components->info('Importmap Laravel was installed succesfully.'); 35 | 36 | return self::SUCCESS; 37 | } 38 | 39 | private function deleteNpmRelatedFiles(): void 40 | { 41 | $files = [ 42 | 'package.json', 43 | 'package-lock.json', 44 | 'webpack.mix.js', 45 | 'postcss.config.js', 46 | 'vite.config.js', 47 | ]; 48 | 49 | collect($files) 50 | ->map(fn ($file) => base_path($file)) 51 | ->filter(fn ($file) => File::exists($file)) 52 | ->each(fn ($file) => File::delete($file)); 53 | } 54 | 55 | private function publishImportmapFiles(): void 56 | { 57 | File::copy(dirname(__DIR__, 2).implode(DIRECTORY_SEPARATOR, ['', 'stubs', 'routes', 'importmap.php']), base_path(implode(DIRECTORY_SEPARATOR, ['routes', 'importmap.php']))); 58 | File::copy(dirname(__DIR__, 2).implode(DIRECTORY_SEPARATOR, ['', 'stubs', 'jsconfig.json']), base_path('jsconfig.json')); 59 | } 60 | 61 | private function convertLocalImportsFromUsingDots(): void 62 | { 63 | (new FixJsImportPaths(rtrim(resource_path('js'), DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR))(); 64 | } 65 | 66 | private function importDependenciesFromNpm(): void 67 | { 68 | if (! File::exists($packageJsonFile = base_path('package.json'))) { 69 | return; 70 | } 71 | 72 | $filteredOutDependencies = [ 73 | '@tailwindcss/forms', 74 | '@tailwindcss/typography', 75 | 'autoprefixer', 76 | 'laravel-vite-plugin', 77 | 'postcss', 78 | 'tailwindcss', 79 | 'vite', 80 | ]; 81 | 82 | $packageJson = json_decode(File::get($packageJsonFile), true); 83 | 84 | $dependencies = collect(array_replace($packageJson['dependencies'] ?? [], $packageJson['devDependencies'] ?? [])) 85 | ->filter(fn ($_version, $package): bool => ! in_array($package, $filteredOutDependencies)) 86 | ->filter(function ($_version, $package): bool { 87 | if ($package !== 'axios') { 88 | return true; 89 | } 90 | 91 | $this->output->warning('It seems you are using axios. Axios is not compatible with importmaps, so we are skipping its installation.'); 92 | 93 | return false; 94 | }) 95 | // Axios has an issue with importmaps, so we'll hardcode the version for now... 96 | ->map(fn ($version, $package): string => $package === 'axios' ? 'axios@0.27' : "\"{$package}@{$version}\""); 97 | 98 | if (trim($dependencies->join('')) === '') { 99 | return; 100 | } 101 | 102 | Process::forever()->run(array_merge([ 103 | $this->phpBinary(), 104 | 'artisan', 105 | 'importmap:pin', 106 | ], $dependencies->all()), function ($_type, $output): void { 107 | $this->output->write($output); 108 | }); 109 | } 110 | 111 | private function updateAppLayouts(): void 112 | { 113 | $this->existingLayoutFiles()->each(fn ($file) => File::put( 114 | $file, 115 | (new ReplaceOrAppendTags)(File::get($file)), 116 | )); 117 | } 118 | 119 | private function existingLayoutFiles() 120 | { 121 | return collect(['app', 'guest']) 122 | ->map(fn ($file) => resource_path("views/layouts/{$file}.blade.php")) 123 | ->filter(fn ($file) => File::exists($file)); 124 | } 125 | 126 | private function configureIgnoredFolder(): void 127 | { 128 | if (Str::contains(File::get(base_path('.gitignore')), 'public/js')) { 129 | return; 130 | } 131 | 132 | File::append(base_path('.gitignore'), "\n/public/js\n"); 133 | } 134 | 135 | private function runStorageLinkCommand(): void 136 | { 137 | if ($this->components->confirm('To be able to serve your assets in development, the resource/js folder will be symlinked to your public/js. Would you like to do that now?', true)) { 138 | if ($this->usingSail() && ! env('LARAVEL_SAIL')) { 139 | Process::forever()->run([ 140 | './vendor/bin/sail', 141 | 'up', 142 | '-d', 143 | ], function ($_type, $output): void { 144 | $this->output->write($output); 145 | }); 146 | 147 | Process::forever()->run([ 148 | './vendor/bin/sail', 149 | 'artisan', 150 | 'storage:link', 151 | ], function ($_type, $output): void { 152 | $this->output->write($output); 153 | }); 154 | } else { 155 | Process::forever()->run([ 156 | $this->phpBinary(), 157 | 'artisan', 158 | 'storage:link', 159 | ], function ($_type, $output): void { 160 | $this->output->write($output); 161 | }); 162 | } 163 | } 164 | } 165 | 166 | private function usingSail(): bool 167 | { 168 | return file_exists(base_path('docker-compose.yml')) && str_contains(file_get_contents(base_path('composer.json')), 'laravel/sail'); 169 | } 170 | 171 | private function phpBinary(): string 172 | { 173 | return (new PhpExecutableFinder)->find(false) ?: 'php'; 174 | } 175 | } 176 | -------------------------------------------------------------------------------- /src/Commands/JsonCommand.php: -------------------------------------------------------------------------------- 1 | asArray(new AssetResolver); 20 | 21 | $this->output->writeln(json_encode($imports, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); 22 | 23 | return self::SUCCESS; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/Commands/OptimizeCommand.php: -------------------------------------------------------------------------------- 1 | call('importmap:clear'); 23 | $this->info('Copying over the files to a dist folder and generating a digest of them...'); 24 | 25 | if ($imports = $importmap->asArray(fn ($file) => $file)) { 26 | $optimizedImports = collect($imports['imports']) 27 | ->reject(fn (string $url) => Str::startsWith($url, ['http://', 'https://'])) 28 | ->map(function (string $file) use ($importmap) { 29 | $sourceFile = $importmap->rootPath.(str_starts_with(trim($file, '/'), 'vendor/') ? '/public/' : '/resources/').trim($file, '/'); 30 | $sourceReplacement = $importmap->rootPath.'/public/dist/'.trim($this->digest($file, $sourceFile), '/'); 31 | 32 | File::ensureDirectoryExists(dirname($sourceReplacement)); 33 | File::copy($sourceFile, $sourceReplacement); 34 | 35 | $replacement = Str::after($sourceReplacement, $importmap->rootPath.'/public/'); 36 | 37 | $this->output->writeln(sprintf( 38 | ' copied %s to %s', 39 | $file, 40 | $replacement, 41 | )); 42 | 43 | return $replacement; 44 | }); 45 | 46 | $this->info('Generating cached manifest...'); 47 | 48 | $preloadModulePaths = $importmap->preloadedModulePaths(fn ($file) => $file); 49 | 50 | $optmizedJson = collect($imports['imports']) 51 | ->map(fn (string $oldFilename, string $module): array => [ 52 | 'module' => $module, 53 | 'path' => $optimizedImports[$module] ?? $oldFilename, 54 | 'preload' => in_array($oldFilename, $preloadModulePaths), 55 | ]) 56 | ->values() 57 | ->all(); 58 | 59 | File::put(Manifest::path(), json_encode($optmizedJson, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); 60 | } 61 | 62 | $this->info('Done!'); 63 | 64 | return self::SUCCESS; 65 | } 66 | 67 | private function digest(string $filename, string $fileSource): string 68 | { 69 | return preg_replace( 70 | '#(\.jsm?)$#', 71 | sprintf('-%s$1', (new FileDigest)($fileSource)), 72 | $filename 73 | ); 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/Commands/OutdatedCommand.php: -------------------------------------------------------------------------------- 1 | outdatedPackages(); 21 | 22 | if ($outdatedPackages->isEmpty()) { 23 | $this->info('No outdated packages found.'); 24 | 25 | return self::SUCCESS; 26 | } 27 | 28 | $this->table( 29 | ['Package', 'Current', 'Latest'], 30 | $outdatedPackages 31 | ->map(fn (OutdatedPackage $package): array => [$package->name, $package->currentVersion, $package->latestVersion ?: $package->error]) 32 | ->all(), 33 | ); 34 | 35 | $this->newLine(); 36 | 37 | $this->error(sprintf( 38 | '%d outdated %s found.', 39 | $outdatedPackages->count(), 40 | Str::plural('package', $outdatedPackages->count()), 41 | )); 42 | 43 | return self::FAILURE; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/Commands/PackagesCommand.php: -------------------------------------------------------------------------------- 1 | packagesWithVersion()->each(fn (PackageVersion $package) => ( 20 | $this->output->writeln(sprintf('%s %s', $package->name, $package->version)) 21 | )); 22 | 23 | return self::SUCCESS; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/Commands/PinCommand.php: -------------------------------------------------------------------------------- 1 | call('importmap:clear'); 40 | 41 | $packages = Arr::wrap($this->argument('packages')); 42 | 43 | if ($imports = $packager->import($packages, $this->option('from-env'), $this->option('from'))) { 44 | $this->importPackages($packager, $imports); 45 | 46 | return Command::SUCCESS; 47 | } 48 | 49 | $this->error(sprintf("Couldn't find any packages in %s on %s", implode(', ', $packages), $this->option('from'))); 50 | 51 | return Command::FAILURE; 52 | } 53 | 54 | private function importPackages(Packager $packager, Collection $imports): void 55 | { 56 | $imports->each(function (string $url, string $package) use ($packager): void { 57 | $this->info(sprintf( 58 | 'Pinning "%s" to %s/%s.js via download from %s', 59 | $package, 60 | $packager->vendorPath, 61 | $package, 62 | $url, 63 | )); 64 | 65 | $packager->download($package, $url); 66 | 67 | $pin = $packager->vendoredPinFor($package, $url); 68 | 69 | if ($packager->packaged($package)) { 70 | // Replace existing pin... 71 | File::put( 72 | $packager->importmapPath, 73 | preg_replace($this->pattern($package), $pin, File::get($packager->importmapPath)), 74 | ); 75 | } else { 76 | // Append to file... 77 | File::append($packager->importmapPath, "{$pin}\n"); 78 | } 79 | }); 80 | } 81 | 82 | private function pattern(string $package): string 83 | { 84 | return sprintf( 85 | '#.*pin\([\'\"]%s[\'\"].*#', 86 | preg_quote($package), 87 | ); 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/Commands/UnpinCommand.php: -------------------------------------------------------------------------------- 1 | argument('packages')); 38 | 39 | if ($imports = $packager->import($packages, $this->option('from-env'), $this->option('from'))) { 40 | $imports->each(function (string $_url, string $package) use ($packager): void { 41 | if ($packager->packaged($package)) { 42 | $this->info(sprintf('Unpinning and removing "%s"', $package)); 43 | 44 | $packager->remove($package); 45 | } 46 | }); 47 | 48 | return self::SUCCESS; 49 | } 50 | 51 | $this->error(sprintf( 52 | "Couldn't find any packages in %s on %s", 53 | implode(', ', $packages), 54 | $this->option('from'), 55 | )); 56 | 57 | return self::FAILURE; 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/Commands/UpdateCommand.php: -------------------------------------------------------------------------------- 1 | outdatedPackages()) > 0) { 29 | $this->call('importmap:pin', [ 30 | 'packages' => $outdatedPackages->pluck('name')->all(), 31 | ]); 32 | } else { 33 | $this->components->info('No oudated packages found.'); 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/Events/FailedToFixImportStatement.php: -------------------------------------------------------------------------------- 1 | getPath()), '/') 19 | )); 20 | 21 | $exception->importStatement = $importStatement; 22 | $exception->file = $file; 23 | 24 | return $exception; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/Exceptions/ImportmapException.php: -------------------------------------------------------------------------------- 1 | rootPath = rtrim($this->rootPath ?: base_path(), DIRECTORY_SEPARATOR); 19 | $this->packages = collect(); 20 | $this->directories = collect(); 21 | } 22 | 23 | public function pin(string $name, ?string $to = null, bool $preload = true): void 24 | { 25 | $this->packages->add(new MappedFile($name, path: $to ?: "js/{$name}.js", preload: $preload)); 26 | } 27 | 28 | public function pinAllFrom(string $dir, ?string $under = null, ?string $to = null, bool $preload = true): void 29 | { 30 | $this->directories->add(new MappedDirectory($dir, $under, $to, $preload)); 31 | } 32 | 33 | public function preloadedModulePaths(callable $assetResolver): array 34 | { 35 | if ($this->hasManifest()) { 36 | return $this->resolvePreloadedModulesFromManifest($assetResolver); 37 | } 38 | 39 | return $this->resolveAssetPaths($this->expandPreloadingPackagesAndDirectories(), $assetResolver); 40 | } 41 | 42 | public function asArray(callable $assetResolver): array 43 | { 44 | if ($this->hasManifest()) { 45 | return $this->resolveImportsFromManifest($assetResolver); 46 | } 47 | 48 | return [ 49 | 'imports' => $this->resolveAssetPaths($this->expandPackagesAndDirectories(), $assetResolver), 50 | ]; 51 | } 52 | 53 | public function getRootPath(): string 54 | { 55 | return $this->rootPath; 56 | } 57 | 58 | public function getFileAbsolutePath(string $relativePath): string 59 | { 60 | return $this->rootPath.str_replace('/', DIRECTORY_SEPARATOR, $relativePath); 61 | } 62 | 63 | private function hasManifest(): bool 64 | { 65 | return File::exists($this->manifestPath()); 66 | } 67 | 68 | private function manifestPath(): string 69 | { 70 | return Manifest::path(); 71 | } 72 | 73 | private function resolvePreloadedModulesFromManifest(callable $assetResolver): array 74 | { 75 | return collect(json_decode(File::get($this->manifestPath()), true)) 76 | ->filter(fn (array $json) => $json['preload']) 77 | ->mapWithKeys(fn (array $json) => [$json['module'] => $assetResolver($json['path'])]) 78 | ->all(); 79 | } 80 | 81 | private function resolveImportsFromManifest(callable $assetResolver): array 82 | { 83 | return [ 84 | 'imports' => collect(json_decode(File::get($this->manifestPath()), true)) 85 | ->mapWithKeys(fn (array $json) => [$json['module'] => $assetResolver($json['path'])]) 86 | ->all(), 87 | ]; 88 | } 89 | 90 | private function expandPreloadingPackagesAndDirectories(): Collection 91 | { 92 | return $this->expandPackagesAndDirectories() 93 | ->filter(fn (MappedFile $mapping): bool => $mapping->preload) 94 | ->values(); 95 | } 96 | 97 | private function expandPackagesAndDirectories(): Collection 98 | { 99 | return $this->packages->collect()->merge($this->expandDirectories()); 100 | } 101 | 102 | private function expandDirectories(): Collection 103 | { 104 | return $this->directories->flatMap(function (MappedDirectory $mapping) { 105 | if (! File::isDirectory($absolutePath = $this->absoluteRootOf($mapping->dir))) { 106 | return []; 107 | } 108 | 109 | return $this->findJavascriptFilesInTree($absolutePath) 110 | ->map(function (SplFileInfo $file) use ($mapping, $absolutePath): ?\Tonysm\ImportmapLaravel\MappedFile { 111 | $moduleFilename = $this->relativePathFrom($file->getRealPath(), $absolutePath); 112 | $moduleName = $this->moduleNameFrom($moduleFilename, $mapping); 113 | $modulePath = $this->modulePathFrom($moduleFilename, $mapping); 114 | 115 | // We're ignoring anything that starts with `vendor`, as that's probably 116 | // being mapped directly as a result of pinning with a --download flag. 117 | if (str_starts_with($moduleFilename, 'vendor')) { 118 | return null; 119 | } 120 | 121 | return new MappedFile($moduleName, $modulePath, $mapping->preload); 122 | }) 123 | ->filter(); 124 | }); 125 | } 126 | 127 | private function absoluteRootOf(string $path): string 128 | { 129 | if (Str::startsWith($path, '/')) { 130 | return str_replace('/', DIRECTORY_SEPARATOR, $path); 131 | } 132 | 133 | return $this->rootPath.DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $path); 134 | } 135 | 136 | private function findJavascriptFilesInTree(string $absolutePath): Collection 137 | { 138 | $allFiles = File::allFiles($absolutePath); 139 | 140 | return collect($allFiles) 141 | ->filter(fn (SplFileInfo $file): bool => in_array($file->getExtension(), ['js', 'jsm'])) 142 | ->values(); 143 | } 144 | 145 | private function relativePathFrom(string $fileAbsolutePath, string $folderAbsolutePath): string 146 | { 147 | return trim(Str::after($fileAbsolutePath, $folderAbsolutePath), DIRECTORY_SEPARATOR); 148 | } 149 | 150 | private function moduleNameFrom(string $moduleFileName, MappedDirectory $mapping): string 151 | { 152 | return str_replace(DIRECTORY_SEPARATOR, '/', implode('/', array_filter([ 153 | $mapping->under, 154 | preg_replace('#([\\\/]?index)?\.jsm?$#', '', $moduleFileName), 155 | ]))); 156 | } 157 | 158 | private function modulePathFrom(string $moduleFilename, MappedDirectory $mapping): string 159 | { 160 | return str_replace(DIRECTORY_SEPARATOR, '/', implode('/', array_filter([ 161 | rtrim((string) $mapping->path ?: $mapping->under, DIRECTORY_SEPARATOR.'/'), 162 | $moduleFilename, 163 | ]))); 164 | } 165 | 166 | private function resolveAssetPaths(Collection $paths, callable $assetResolver): array 167 | { 168 | return $paths->mapWithKeys(fn (MappedFile $mapping) => [$mapping->name => $assetResolver($mapping->path)])->all(); 169 | } 170 | } 171 | -------------------------------------------------------------------------------- /src/ImportmapLaravelServiceProvider.php: -------------------------------------------------------------------------------- 1 | name('importmap') 20 | ->hasConfigFile() 21 | ->hasViews() 22 | ->hasCommand(Commands\InstallCommand::class) 23 | ->hasCommand(Commands\OptimizeCommand::class) 24 | ->hasCommand(Commands\ClearCacheCommand::class) 25 | ->hasCommand(Commands\JsonCommand::class) 26 | ->hasCommand(Commands\PinCommand::class) 27 | ->hasCommand(Commands\UnpinCommand::class) 28 | ->hasCommand(Commands\OutdatedCommand::class) 29 | ->hasCommand(Commands\AuditCommand::class) 30 | ->hasCommand(Commands\PackagesCommand::class) 31 | ->hasCommand(Commands\UpdateCommand::class); 32 | } 33 | 34 | public function packageRegistered(): void 35 | { 36 | $this->app->scoped(Importmap::class, fn (): \Tonysm\ImportmapLaravel\Importmap => new Importmap); 37 | 38 | $this->app->bind('importmap-laravel', Importmap::class); 39 | } 40 | 41 | public function packageBooted(): void 42 | { 43 | if (file_exists(base_path('routes/importmap.php'))) { 44 | require base_path('routes/importmap.php'); 45 | } 46 | 47 | if (app()->environment('local') && app()->runningInConsole()) { 48 | config()->set('filesystems.links', config('filesystems.links', []) + [ 49 | public_path('js') => resource_path('js'), 50 | ]); 51 | } 52 | 53 | $this->configureComponents(); 54 | } 55 | 56 | private function configureComponents(): void 57 | { 58 | $this->callAfterResolving('blade.compiler', function (BladeCompiler $blade): void { 59 | $blade->anonymousComponentPath(__DIR__.'/../resources/views/components', 'importmap'); 60 | }); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/Manifest.php: -------------------------------------------------------------------------------- 1 | configPath ??= base_path('routes/importmap.php'); 16 | } 17 | 18 | public function outdatedPackages(): Collection 19 | { 20 | return $this->packagesWithVersion() 21 | ->reduce(function (Collection $outdatedPackages, PackageVersion $package) { 22 | $latestVersion = null; 23 | $error = null; 24 | 25 | if (! ($response = $this->getPackage($package))) { 26 | $error = 'Response error'; 27 | } elseif ($response['error'] ?? false) { 28 | $error = $response['error']; 29 | } else { 30 | $latestVersion = $this->findLatestVersion($response); 31 | 32 | if (! $this->outdated($package->version, $latestVersion)) { 33 | return $outdatedPackages; 34 | } 35 | } 36 | 37 | return $outdatedPackages->add(new OutdatedPackage( 38 | name: $package->name, 39 | currentVersion: $package->version, 40 | latestVersion: $latestVersion, 41 | error: $error, 42 | )); 43 | }, collect()); 44 | } 45 | 46 | public function vulnerablePackages(): Collection 47 | { 48 | $data = $this->packagesWithVersion() 49 | ->mapWithKeys(fn (PackageVersion $package) => [ 50 | $package->name => [$package->version], 51 | ]) 52 | ->all(); 53 | 54 | return $this->getAudit($data) 55 | ->collect() 56 | ->flatMap(fn (array $vulnerabilities, string $package) => collect($vulnerabilities) 57 | ->map(fn (array $vulnerability): \Tonysm\ImportmapLaravel\VulnerablePackage => new VulnerablePackage( 58 | name: $package, 59 | severity: $vulnerability['severity'], 60 | vulnerableVersions: $vulnerability['vulnerable_versions'], 61 | vulnerability: $vulnerability['title'], 62 | ))) 63 | ->sortBy([ 64 | ['name', 'asc'], 65 | ['severity', 'asc'], 66 | ]) 67 | ->values(); 68 | } 69 | 70 | public function packagesWithVersion(): Collection 71 | { 72 | $content = File::get($this->configPath); 73 | 74 | return $this->findPackagesFromCdnMatches($content) 75 | ->merge($this->findPackagesFromLocalMatches($content)) 76 | ->unique('name') 77 | ->values(); 78 | } 79 | 80 | private function findPackagesFromCdnMatches(string $content) 81 | { 82 | preg_match_all('/^Importmap\:\:pin\(.*(?<=npm:|npm\/|skypack\.dev\/|unpkg\.com\/)(.*)(?=@\d+\.\d+\.\d+)@(\d+\.\d+\.\d+(?:[^\/\s"\']*)).*\)\;\r?$/m', $content, $matches); 83 | 84 | if (count($matches) !== 3) { 85 | return collect(); 86 | } 87 | 88 | return collect($matches[1]) 89 | ->zip($matches[2]) 90 | ->map(fn ($items): \Tonysm\ImportmapLaravel\PackageVersion => new PackageVersion(name: $items[0], version: $items[1])) 91 | ->values(); 92 | } 93 | 94 | private function findPackagesFromLocalMatches(string $content) 95 | { 96 | preg_match_all('/pin\([\'\"](.*?)[\'\"].*\);\s+\/\/\s+.*?@(\d+\.\d+\.\d+.*?)\s/m', $content, $matches); 97 | 98 | if (count($matches) !== 3) { 99 | return collect(); 100 | } 101 | 102 | return collect($matches[1]) 103 | ->zip($matches[2]) 104 | ->map(fn ($items): \Tonysm\ImportmapLaravel\PackageVersion => new PackageVersion(name: $items[0], version: $items[1])) 105 | ->values(); 106 | } 107 | 108 | private function getPackage(PackageVersion $package) 109 | { 110 | $response = Http::get($this->baseUrl.'/'.$package->name); 111 | 112 | if (! $response->ok()) { 113 | return null; 114 | } 115 | 116 | return $response->json(); 117 | } 118 | 119 | private function findLatestVersion(array $json) 120 | { 121 | $latestVersion = data_get($json, 'dist-tags.latest'); 122 | 123 | if ($latestVersion) { 124 | return $latestVersion; 125 | } 126 | 127 | if (! isset($json['versions'])) { 128 | return null; 129 | } 130 | 131 | return collect($json['versions']) 132 | ->keys() 133 | ->sort(fn ($versionA, $versionB): int => version_compare($versionB, $versionA)) 134 | ->values() 135 | ->first(); 136 | } 137 | 138 | private function outdated(string $currentVersion, string $latestVersion): bool 139 | { 140 | return version_compare($currentVersion, $latestVersion) === -1; 141 | } 142 | 143 | private function getAudit(array $packages) 144 | { 145 | $response = Http::asJson() 146 | ->post($this->baseUrl.'/-/npm/v1/security/advisories/bulk', $packages); 147 | 148 | if (! $response->ok()) { 149 | return collect(); 150 | } 151 | 152 | return $response->collect(); 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /src/OutdatedPackage.php: -------------------------------------------------------------------------------- 1 | importmapPath = file_exists(base_path($importmapPath)) 21 | ? base_path($this->importmapPath) 22 | : $this->importmapPath; 23 | } 24 | 25 | public function import(array $packages, string $env, string $from) 26 | { 27 | $response = Http::post(static::$ENDPOINT, [ 28 | 'install' => $packages, 29 | 'flattenScope' => true, 30 | 'env' => ['browser', 'module', $env], 31 | 'provider' => $this->normalizeProvider($from), 32 | ]); 33 | 34 | return match ($response->status()) { 35 | 200 => $response->collect('map.imports'), 36 | 404, 401 => null, 37 | default => $this->handleFailureResponse($response), 38 | }; 39 | } 40 | 41 | public function pinFor(string $package, string $url): string 42 | { 43 | return sprintf('Importmap::pin("%s", to: "%s");', $package, $url); 44 | } 45 | 46 | public function download(string $package, string $url): void 47 | { 48 | File::ensureDirectoryExists(base_path($this->vendorPath)); 49 | File::delete(base_path($this->vendoredPackageName($package))); 50 | File::put(base_path($this->vendoredPackageName($package)), $this->withoutSourceMapComments(Http::get($url)->body())); 51 | } 52 | 53 | public function vendoredPinFor(string $package, string $url): string 54 | { 55 | $version = $this->extractPackageVersionFrom($url); 56 | 57 | return sprintf( 58 | 'Importmap::pin("%s", to: "%s"); // %s%s downloaded from %s', 59 | $package, 60 | Str::after($this->vendoredPackageName($package), 'resources'), 61 | $package, 62 | $version, 63 | $url, 64 | ); 65 | } 66 | 67 | public function packaged(string $package): bool 68 | { 69 | return (bool) preg_match( 70 | sprintf('#Importmap::pin\(["\']%s["\']#', preg_quote($package)), 71 | File::get($this->importmapPath), 72 | ); 73 | } 74 | 75 | public function remove(string $package): void 76 | { 77 | $this->removeExistingPackageFile($package); 78 | $this->removePackageFromImportmap($package); 79 | } 80 | 81 | private function removeExistingPackageFile(string $package): void 82 | { 83 | if (File::exists(base_path($this->vendoredPackageName($package)))) { 84 | File::delete(base_path($this->vendoredPackageName($package))); 85 | } 86 | 87 | if (File::exists(base_path($this->vendorPath)) && count(File::files(base_path($this->vendorPath))) === 0) { 88 | File::deleteDirectory(base_path($this->vendorPath)); 89 | } 90 | } 91 | 92 | private function removePackageFromImportmap(string $package): void 93 | { 94 | $contents = collect(File::lines($this->importmapPath)) 95 | ->reject(fn (string $line): int|false => ( 96 | preg_match(sprintf('#Importmap::pin\(["\']%s["\']#', preg_quote($package)), $line) 97 | )) 98 | ->join(PHP_EOL); 99 | 100 | File::put($this->importmapPath, $contents); 101 | } 102 | 103 | private function withoutSourceMapComments(string $contents): string 104 | { 105 | return preg_replace('#//\# sourceMappingURL=.*#', '', $contents); 106 | } 107 | 108 | private function vendoredPackageName(string $package): string 109 | { 110 | return sprintf('%s/%s', rtrim($this->vendorPath, '/'), $this->packageFilename($package)); 111 | } 112 | 113 | private function packageFilename(string $package): string 114 | { 115 | $replacements = [ 116 | '/' => '--', 117 | '#' => '--', 118 | '.js' => '', 119 | ]; 120 | 121 | return str_replace(array_keys($replacements), array_values($replacements), $package).'.js'; 122 | } 123 | 124 | private function extractPackageVersionFrom(string $url): string 125 | { 126 | preg_match('#(@\d+\.\d+\.\d+.*?)/#', $url, $matches); 127 | 128 | if (! ($matches[1] ?? false)) { 129 | return 'Unknown Version'; 130 | } 131 | 132 | return $matches[1]; 133 | } 134 | 135 | private function handleFailureResponse(Response $response): void 136 | { 137 | if ($errorMessage = $response->json('error', null)) { 138 | throw Exceptions\ImportmapException::withResponseError($errorMessage); 139 | } 140 | 141 | throw Exceptions\ImportmapException::withUnexpectedResponseCode($response->status()); 142 | } 143 | 144 | private function normalizeProvider(string $provider): string 145 | { 146 | return match ($provider) { 147 | 'jspm' => 'jspm.io', 148 | default => $provider, 149 | }; 150 | } 151 | } 152 | -------------------------------------------------------------------------------- /src/VulnerablePackage.php: -------------------------------------------------------------------------------- 1 |