├── .symfony.bundle.yaml ├── LICENSE ├── README.md ├── composer.json ├── config └── services.php ├── doc └── index.rst └── src ├── AssetMapper ├── SassCssCompiler.php └── SassPublicPathAssetPathResolver.php ├── Command └── SassBuildCommand.php ├── DependencyInjection └── SymfonycastsSassExtension.php ├── Listener └── PreAssetsCompileEventListener.php ├── SassBinary.php ├── SassBuilder.php └── SymfonycastsSassBundle.php /.symfony.bundle.yaml: -------------------------------------------------------------------------------- 1 | branches: ["main"] 2 | maintained_branches: ["main"] 3 | doc_dir: "doc" 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 SymfonyCasts 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 | Sass For Symfony! 2 | ================= 3 | 4 | [![CI](https://github.com/SymfonyCasts/sass-bundle/actions/workflows/ci.yaml/badge.svg)](https://github.com/SymfonyCasts/sass-bundle/actions/workflows/ci.yaml) 5 | 6 | This bundle make it easy to use Sass with Symfony's AssetMapper Component 7 | (no Node required!). 8 | 9 | - Automatically downloads the correct Sass binary 10 | - Adds a ``sass:build`` command to build and watch your Sass changes 11 | 12 | ## Documentation 13 | 14 | Read the documentation at: https://symfony.com/bundles/SassBundle/current/index.html 15 | 16 | ## Support us & Symfony 17 | 18 | Is this package useful! We're *thrilled* 😍! 19 | 20 | A lot of time & effort from the Symfonycasts team & the Symfony community 21 | goes into creating and maintaining these packages. You can support us + 22 | Symfony (and learn a bucket-load) by grabbing a subscription to [SymfonyCasts](https://symfonycasts.com)! 23 | 24 | ## Credits 25 | 26 | - [Ryan Weaver](https://github.com/weaverryan) 27 | - [Mathéo Daninos](https://github.com/Webmamba) 28 | - [All Contributors](../../contributors) 29 | 30 | ## License 31 | 32 | MIT License (MIT): see the [License File](LICENSE.md) for more details. 33 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "symfonycasts/sass-bundle", 3 | "description": "Delightful Sass Support for Symfony + AssetMapper", 4 | "license": "MIT", 5 | "type": "library", 6 | "keywords": ["asset-mapper", "sass"], 7 | "authors": [ 8 | { 9 | "name": "Mathéo Daninos", 10 | "homepage": "https://github.com/WebMamba" 11 | } 12 | ], 13 | "require": { 14 | "php": ">=8.1", 15 | "symfony/asset-mapper": "^6.3|^7.0", 16 | "symfony/console": "^5.4|^6.3|^7.0", 17 | "symfony/filesystem": "^5.4|^6.3|^7.0", 18 | "symfony/http-client": "^5.4|^6.3|^7.0", 19 | "symfony/process": "^5.4|^6.3|^7.0" 20 | }, 21 | "require-dev": { 22 | "matthiasnoback/symfony-config-test": "^5.0", 23 | "symfony/framework-bundle": "^6.3|^7.0", 24 | "symfony/phpunit-bridge": "^6.3.9|^7.0", 25 | "phpunit/phpunit": "^9.6" 26 | }, 27 | "minimum-stability": "dev", 28 | "autoload": { 29 | "psr-4": { 30 | "Symfonycasts\\SassBundle\\": "src/" 31 | } 32 | }, 33 | "autoload-dev": { 34 | "psr-4": { 35 | "Symfonycasts\\SassBundle\\Tests\\": "tests/" 36 | } 37 | }, 38 | "scripts": { 39 | "tools:upgrade": [ 40 | "@tools:upgrade:php-cs-fixer", 41 | "@tools:upgrade:phpstan" 42 | ], 43 | "tools:upgrade:php-cs-fixer": "composer upgrade -W -d tools/php-cs-fixer", 44 | "tools:upgrade:phpstan": "composer upgrade -W -d tools/phpstan", 45 | "tools:run": [ 46 | "@tools:run:php-cs-fixer", 47 | "@tools:run:phpstan" 48 | ], 49 | "tools:run:php-cs-fixer": "tools/php-cs-fixer/vendor/bin/php-cs-fixer fix", 50 | "tools:run:phpstan": "tools/phpstan/vendor/bin/phpstan --memory-limit=1G" 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /config/services.php: -------------------------------------------------------------------------------- 1 | services() 16 | ->set('sass.builder', SassBuilder::class) 17 | ->args([ 18 | abstract_arg('path to sass files'), 19 | abstract_arg('path to css directory'), 20 | param('kernel.project_dir'), 21 | abstract_arg('path to binary'), 22 | abstract_arg('sass options'), 23 | ]) 24 | 25 | ->set('sass.command.build', SassBuildCommand::class) 26 | ->args([ 27 | service('sass.builder') 28 | ]) 29 | ->tag('console.command') 30 | 31 | ->set('sass.css_asset_compiler', SassCssCompiler::class) 32 | ->tag('asset_mapper.compiler', [ 33 | 'priority' => 10 34 | ]) 35 | ->args([ 36 | abstract_arg('path to scss files'), 37 | abstract_arg('path to css output directory'), 38 | param('kernel.project_dir'), 39 | service('sass.builder'), 40 | ]) 41 | 42 | ->set('sass.public_asset_path_resolver', SassPublicPathAssetPathResolver::class) 43 | ->decorate('asset_mapper.public_assets_path_resolver') 44 | ->args([ 45 | service('.inner') 46 | ]) 47 | 48 | ->set('sass.listener.pre_assets_compile', PreAssetsCompileEventListener::class) 49 | ->args([ 50 | service('sass.builder') 51 | ]) 52 | ->tag('kernel.event_listener', [ 53 | 'event' => PreAssetsCompileEvent::class, 54 | 'method' => '__invoke' 55 | ]) 56 | ; 57 | ; 58 | }; 59 | -------------------------------------------------------------------------------- /doc/index.rst: -------------------------------------------------------------------------------- 1 | Sass For Symfony! 2 | ================= 3 | 4 | This bundle makes it easy to use Sass with Symfony's AssetMapper Component 5 | (no Node required!). 6 | 7 | - Automatically detects the Sass binary installed in the system 8 | - Automatically downloads the correct Sass binary if it's not detected in the system 9 | - Adds a ``sass:build`` command to build and watch your Sass changes 10 | 11 | .. tip:: 12 | 13 | While this bundle is great, you may *not* need to use Sass! Native CSS now supports 14 | variables and nesting. See `Is it time to drop Sass? `_ 15 | article for some more details. 16 | 17 | Installation 18 | ------------ 19 | 20 | Install the bundle: 21 | 22 | .. code-block:: terminal 23 | 24 | $ composer require symfonycasts/sass-bundle 25 | 26 | Usage 27 | ----- 28 | 29 | Start by writing your first Sass file ``assets/styles/app.scss``, and let's add some basic style: 30 | 31 | .. code-block:: scss 32 | 33 | /* assets/styles/app.scss */ 34 | 35 | $red: #fc030b; 36 | 37 | body { 38 | background: $red; 39 | } 40 | 41 | Then point your styles in your template: 42 | 43 | .. code-block:: html+twig 44 | 45 | {# templates/base.html.twig #} 46 | 47 | {% block stylesheets %} 48 | 49 | {% endblock %} 50 | 51 | That's right! You point directly to the ``.scss`` file. But don't worry, the final built ``.css`` file will be returned! 52 | 53 | Then run the command: 54 | 55 | .. code-block:: terminal 56 | 57 | $ php bin/console sass:build --watch 58 | 59 | And that's it! 60 | 61 | Symfony CLI 62 | ~~~~~~~~~~~ 63 | 64 | If using the `Symfony CLI `_, you can add the build 65 | command as a `worker `_ 66 | to be started whenever you run ``symfony server:start``: 67 | 68 | .. code-block:: yaml 69 | # .symfony.local.yaml 70 | workers: 71 | # ... 72 | sass: 73 | cmd: ['symfony', 'console', 'sass:build', '--watch'] 74 | 75 | .. tip:: 76 | 77 | If running ``symfony server:start`` as a daemon, you can run 78 | ``symfony server:log`` to tail the output of the worker. 79 | 80 | How Does it Work? 81 | ----------------- 82 | 83 | The first time you run one of the Sass commands, the bundle will automatically try to detect 84 | the correct Sass binary installed in the system and use it. If the binary is not found, 85 | the bundle will automatically download the correct one for your system and put it into 86 | the ``bin/dart-sass`` directory. 87 | 88 | When you run ``sass:build``, that binary is used to compile Sass files into a ``var/sass/app.built.css`` 89 | file. Finally, when the contents of ``assets/styles/app.scss`` are requested, the bundle swaps 90 | the contents of that file with the contents of ``var/sass/app.built.css``. Nice! 91 | 92 | Excluding Sass Files from AssetMapper 93 | ------------------------------------- 94 | 95 | Because you have ``.scss`` files in your ``assets/`` directory, when you deploy, these 96 | source files will be copied into the ``public/assets/`` directory. To prevent that, 97 | you can exclude them from AssetMapper: 98 | 99 | .. code-block:: yaml 100 | # config/packages/asset_mapper.yaml 101 | framework: 102 | asset_mapper: 103 | paths: 104 | - assets/ 105 | excluded_patterns: 106 | - '*/assets/styles/_*.scss' 107 | - '*/assets/styles/**/_*.scss' 108 | 109 | .. note:: 110 | 111 | Be sure not to exclude your *main* SCSS file (e.g. ``assets/styles/app.scss``): 112 | this *is* used in AssetMapper and its contents are swapped for the final, built CSS. 113 | 114 | 115 | How to Get Source Sass Files for 3rd-Party Libraries 116 | ---------------------------------------------------- 117 | 118 | The easiest way to get 3rd-party Sass files is via `Composer `_. For example, see 119 | the section below to know how to get the source Sass files for `Bootstrap `_. 120 | 121 | But if you're using a library that isn't available via Composer, you’ll need 122 | to either download it to your app manually or grab it via NPM. 123 | 124 | Using Bootstrap Sass 125 | ~~~~~~~~~~~~~~~~~~~~ 126 | 127 | `Bootstrap `_ is available as Sass, allowing you to customize 128 | the look and feel of your app. An easy way to get the source Sass files is via a Composer package: 129 | 130 | .. code-block:: terminal 131 | 132 | $ composer require twbs/bootstrap 133 | 134 | Now, import the core ``bootstrap.scss`` from your ``app.scss`` file: 135 | 136 | .. code-block:: scss 137 | 138 | /* Override some Bootstrap variables */ 139 | $red: #FB4040; 140 | 141 | @import '../../vendor/twbs/bootstrap/scss/bootstrap'; 142 | 143 | Using Bootswatch Sass 144 | ~~~~~~~~~~~~~~~~~~~~~ 145 | 146 | `Bootswatch `_ is also available as Sass and provides 147 | free themes for Bootstrap. An easy way to get the source Bootswatch Sass files 148 | is via a Composer package: 149 | 150 | .. code-block:: terminal 151 | 152 | $ composer require thomaspark/bootswatch 153 | 154 | Now, import the core Sass theme files along with ``bootstrap.scss`` from your 155 | ``app.scss`` file: 156 | 157 | .. code-block:: scss 158 | 159 | @import '../../vendor/thomaspark/bootswatch/dist/[theme]/variables'; 160 | @import '../../vendor/twbs/bootstrap/scss/bootstrap'; 161 | @import '../../vendor/thomaspark/bootswatch/dist/[theme]/bootswatch'; 162 | 163 | Don't forget to install the ``twbs/bootstrap`` main package as well because 164 | Bootswatch needed it. See the previous section for more details. 165 | 166 | Deploying 167 | --------- 168 | 169 | When you deploy, run ``sass:build`` command before the ``asset-map:compile`` command so the built file is available: 170 | 171 | .. code-block:: terminal 172 | 173 | $ php bin/console sass:build 174 | $ php bin/console asset-map:compile 175 | 176 | Limitation: ``url()`` Relative Paths 177 | ------------------------------------ 178 | 179 | When using ``url()`` inside a Sass file, currently, the path must be relative to the *root* ``.scss`` file. 180 | For example, suppose the root ``.scss`` file is: 181 | 182 | .. code-block:: scss 183 | 184 | /* assets/styles/app.scss */ 185 | import 'tools/base'; 186 | 187 | Assume there is an ``assets/images/login-bg.png`` file that you want to refer to from ``base.css``: 188 | 189 | .. code-block:: scss 190 | 191 | /* assets/styles/tools/base.scss */ 192 | .splash { 193 | /* This SHOULD work, but doesn't */ 194 | background-image: url('../../images/login-bg.png'); 195 | 196 | /* This DOES work: it's relative to app.scss */ 197 | background-image: url('../images/login-bg.png'); 198 | } 199 | 200 | It should be possible to use ``url()`` with a path relative to the current file. However, that is not 201 | currently possible. See `this issue `_ for more details. 202 | 203 | Configuration 204 | ------------- 205 | 206 | To see the full config from this bundle, run: 207 | 208 | .. code-block:: terminal 209 | 210 | $ php bin/console config:dump symfonycasts_sass 211 | 212 | 213 | Source Sass file 214 | ~~~~~~~~~~~~~~~~ 215 | 216 | The main option is the ``root_sass`` option, which defaults to ``assets/styles/app.scss``. 217 | This represents the source Sass file: 218 | 219 | .. code-block:: yaml 220 | 221 | # config/packages/symfonycasts_sass.yaml 222 | symfonycasts_sass: 223 | root_sass: 'assets/styles/app.scss' 224 | 225 | .. note:: 226 | 227 | The ``root_sass`` option also supports an array of paths that represents different source Sass files: 228 | 229 | .. code-block:: yaml 230 | 231 | symfonycasts_sass: 232 | root_sass: 233 | - '%kernel.project_dir%/assets/scss/app.scss' 234 | 235 | Sass CLI Options 236 | ~~~~~~~~~~~~~~~~ 237 | 238 | You can configure most of the `Dart Sass CLI options `_: 239 | 240 | .. code-block:: yaml 241 | 242 | # config/packages/symfonycasts_sass.yaml 243 | symfonycasts_sass: 244 | sass_options: 245 | # The output style for the compiled CSS files: expanded or compressed. Defaults to expanded. 246 | # style: expanded 247 | 248 | # Emit a @charset or BOM for CSS with non-ASCII characters. Defaults to true in Dart Sass. 249 | # charset: true 250 | 251 | # Register additional load paths. Defaults to empty array. 252 | # load_path: [] 253 | 254 | # Whether to generate source maps. Defaults to true when "kernel.debug" is true. 255 | # source_map: true 256 | 257 | # Embed source file contents in source maps. Defaults to false. 258 | # embed_sources: 259 | 260 | # Embed source map contents in CSS. Defaults to false. 261 | # embed_source_map: 262 | 263 | # Don't print warnings. Defaults to false. 264 | # quiet: 265 | 266 | # Don't print deprecated warnings for dependencies. Defaults to false. 267 | # quiet_deps: 268 | 269 | # Don't compile more files once an error is encountered. Defaults to false. 270 | # stop_on_error: 271 | 272 | # Print full Dart stack traces for exceptions. Defaults to false. 273 | # trace: 274 | 275 | 276 | Using a different binary 277 | ------------------------ 278 | 279 | This bundle has already detected or installed for you the right binary. However, if you already have a binary 280 | installed on your machine and somehow the bundle has not been able to find it automatically - you can instruct 281 | the bundle to use that binary, set the ``binary`` option: 282 | 283 | .. code-block:: yaml 284 | 285 | symfonycasts_sass: 286 | binary: 'node_modules/.bin/sass' 287 | 288 | .. tip:: 289 | 290 | If a path in the ``binary`` option is explicitly specified - the bundle will just use it 291 | which means it will not try to search a binary itself or download it automatically for your system. 292 | To let the bundle take care of it automatically - do not specify the ``binary`` option. 293 | 294 | 295 | 296 | Register Additional Load Paths 297 | ------------------------------ 298 | 299 | You can provide additional `load paths `_ to resolve modules with the ``load_path`` option. 300 | 301 | For example, an alternative way to use Bootstrap would be to register the vendor path: 302 | 303 | .. code-block:: yaml 304 | 305 | # config/packages/symfonycasts_sass.yaml 306 | symfonycasts_sass: 307 | sass_options: 308 | load_path: 309 | - '%kernel.project_dir%/vendor/twbs/bootstrap/scss' 310 | 311 | And then import bootstrap from ``app.scss`` with: 312 | 313 | .. code-block:: scss 314 | 315 | @import 'bootstrap'; 316 | -------------------------------------------------------------------------------- /src/AssetMapper/SassCssCompiler.php: -------------------------------------------------------------------------------- 1 | 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | namespace Symfonycasts\SassBundle\AssetMapper; 11 | 12 | use Symfony\Component\AssetMapper\AssetMapperInterface; 13 | use Symfony\Component\AssetMapper\Compiler\AssetCompilerInterface; 14 | use Symfony\Component\AssetMapper\MappedAsset; 15 | use Symfony\Component\Filesystem\Path; 16 | use Symfonycasts\SassBundle\SassBuilder; 17 | 18 | class SassCssCompiler implements AssetCompilerInterface 19 | { 20 | public function __construct( 21 | private array $scssPaths, 22 | private string $cssPathDirectory, 23 | private string $projectDir, 24 | private readonly SassBuilder $sassBuilder, 25 | ) { 26 | } 27 | 28 | public function supports(MappedAsset $asset): bool 29 | { 30 | foreach ($this->scssPaths as $path) { 31 | $absolutePath = Path::isAbsolute($path) ? $path : Path::makeAbsolute($path, $this->projectDir); 32 | 33 | if (realpath($asset->sourcePath) === realpath($absolutePath)) { 34 | return true; 35 | } 36 | } 37 | 38 | return false; 39 | } 40 | 41 | public function compile(string $content, MappedAsset $asset, AssetMapperInterface $assetMapper): string 42 | { 43 | $cssFile = $this->sassBuilder->guessCssNameFromSassFile($asset->sourcePath, $this->cssPathDirectory); 44 | 45 | $asset->addFileDependency($cssFile); 46 | 47 | if (!is_file($cssFile) || ($content = file_get_contents($cssFile)) === false) { 48 | throw new \RuntimeException('The file '.$cssFile.' doesn\'t exist, run php bin/console sass:build'); 49 | } 50 | 51 | return $content; 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/AssetMapper/SassPublicPathAssetPathResolver.php: -------------------------------------------------------------------------------- 1 | 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | namespace Symfonycasts\SassBundle\AssetMapper; 11 | 12 | use Symfony\Component\AssetMapper\Path\PublicAssetsPathResolverInterface; 13 | 14 | class SassPublicPathAssetPathResolver implements PublicAssetsPathResolverInterface 15 | { 16 | public function __construct(private readonly PublicAssetsPathResolverInterface $decorator) 17 | { 18 | } 19 | 20 | public function resolvePublicPath(string $logicalPath): string 21 | { 22 | $path = $this->decorator->resolvePublicPath($logicalPath); 23 | 24 | if (str_contains($path, '.scss')) { 25 | return str_replace('.scss', '.css', $path); 26 | } 27 | 28 | return $path; 29 | } 30 | 31 | public function getPublicFilesystemPath(): string 32 | { 33 | if (!method_exists($this->decorator, 'getPublicFilesystemPath')) { 34 | throw new \Exception('Something weird happened, we should never reach this line!'); 35 | } 36 | 37 | $path = $this->decorator->getPublicFilesystemPath(); 38 | 39 | if (str_contains($path, '.scss')) { 40 | return str_replace('.scss', '.css', $path); 41 | } 42 | 43 | return $path; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/Command/SassBuildCommand.php: -------------------------------------------------------------------------------- 1 | 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | namespace Symfonycasts\SassBundle\Command; 11 | 12 | use Symfony\Component\Console\Attribute\AsCommand; 13 | use Symfony\Component\Console\Command\Command; 14 | use Symfony\Component\Console\Input\InputInterface; 15 | use Symfony\Component\Console\Output\OutputInterface; 16 | use Symfony\Component\Console\Style\SymfonyStyle; 17 | use Symfonycasts\SassBundle\SassBuilder; 18 | 19 | #[AsCommand( 20 | name: 'sass:build', 21 | description: 'Builds the Sass assets' 22 | )] 23 | class SassBuildCommand extends Command 24 | { 25 | public function __construct( 26 | private SassBuilder $sassBuilder, 27 | ) { 28 | parent::__construct(); 29 | } 30 | 31 | protected function configure(): void 32 | { 33 | $this->addOption('watch', 'w', null, 'Watch for changes and rebuild automatically'); 34 | } 35 | 36 | protected function execute(InputInterface $input, OutputInterface $output): int 37 | { 38 | $io = new SymfonyStyle($input, $output); 39 | 40 | $this->sassBuilder->setOutput($io); 41 | 42 | $process = $this->sassBuilder->runBuild( 43 | $input->getOption('watch') 44 | ); 45 | 46 | $process->wait(function ($type, $buffer) use ($io) { 47 | $io->write($buffer); 48 | }); 49 | 50 | if (!$process->isSuccessful()) { 51 | $io->error('Sass build failed'); 52 | 53 | return self::FAILURE; 54 | } 55 | 56 | return self::SUCCESS; 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/DependencyInjection/SymfonycastsSassExtension.php: -------------------------------------------------------------------------------- 1 | 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | namespace Symfonycasts\SassBundle\DependencyInjection; 11 | 12 | use Symfony\Component\Config\Definition\Builder\ArrayNodeDefinition; 13 | use Symfony\Component\Config\Definition\Builder\TreeBuilder; 14 | use Symfony\Component\Config\Definition\ConfigurationInterface; 15 | use Symfony\Component\Config\FileLocator; 16 | use Symfony\Component\DependencyInjection\ContainerBuilder; 17 | use Symfony\Component\DependencyInjection\Extension\Extension; 18 | use Symfony\Component\DependencyInjection\Loader; 19 | 20 | class SymfonycastsSassExtension extends Extension implements ConfigurationInterface 21 | { 22 | public function load(array $configs, ContainerBuilder $container): void 23 | { 24 | $loader = new Loader\PhpFileLoader($container, new FileLocator(__DIR__.'/../../config')); 25 | $loader->load('services.php'); 26 | 27 | $configuration = $this->getConfiguration($configs, $container); 28 | $config = $this->processConfiguration($configuration, $configs); 29 | 30 | // BC Layer with SassBundle < 0.4 31 | if (isset($config['embed_sourcemap'])) { 32 | $config['sass_options']['embed_source_map'] = $config['embed_sourcemap']; 33 | } 34 | 35 | $container->findDefinition('sass.builder') 36 | ->replaceArgument(0, $config['root_sass']) 37 | ->replaceArgument(1, '%kernel.project_dir%/var/sass') 38 | ->replaceArgument(3, $config['binary']) 39 | ->replaceArgument(4, $config['sass_options']) 40 | ; 41 | 42 | $container->findDefinition('sass.css_asset_compiler') 43 | ->replaceArgument(0, $config['root_sass']) 44 | ->replaceArgument(1, '%kernel.project_dir%/var/sass') 45 | ; 46 | } 47 | 48 | public function getConfiguration(array $config, ContainerBuilder $container): ?ConfigurationInterface 49 | { 50 | return $this; 51 | } 52 | 53 | public function getConfigTreeBuilder(): TreeBuilder 54 | { 55 | $treeBuilder = new TreeBuilder('symfonycasts_sass'); 56 | 57 | $rootNode = $treeBuilder->getRootNode(); 58 | \assert($rootNode instanceof ArrayNodeDefinition); 59 | 60 | $rootNode 61 | ->children() 62 | ->arrayNode('root_sass') 63 | ->info('Path to your Sass root file') 64 | ->beforeNormalization()->castToArray()->end() 65 | ->cannotBeEmpty() 66 | ->scalarPrototype() 67 | ->end() 68 | ->validate() 69 | ->ifTrue(static function (array $paths): bool { 70 | if (1 === \count($paths)) { 71 | return false; 72 | } 73 | 74 | $filenames = []; 75 | foreach ($paths as $path) { 76 | $filename = basename($path, '.scss'); 77 | $filenames[$filename] = $filename; 78 | } 79 | 80 | return \count($filenames) !== \count($paths); 81 | }) 82 | ->thenInvalid('The "root_sass" paths need to end with unique filenames.') 83 | ->end() 84 | ->defaultValue(['%kernel.project_dir%/assets/styles/app.scss']) 85 | ->end() 86 | ->scalarNode('binary') 87 | ->info('The Sass binary to use') 88 | ->defaultNull() 89 | ->end() 90 | ->arrayNode('sass_options') 91 | ->addDefaultsIfNotSet() 92 | ->children() 93 | ->enumNode('style') 94 | ->info('The style of the generated CSS: compressed or expanded.') 95 | ->values(['compressed', 'expanded']) 96 | ->defaultValue('expanded') 97 | ->end() 98 | ->booleanNode('charset') 99 | ->info('Whether to include the charset declaration in the generated Sass.') 100 | ->end() 101 | ->booleanNode('error_css') 102 | ->info('Emit a CSS file when an error occurs.') 103 | ->end() 104 | ->booleanNode('source_map') 105 | ->info('Whether to generate source maps.') 106 | ->defaultValue(true) 107 | ->end() 108 | ->booleanNode('embed_sources') 109 | ->info('Embed source file contents in source maps.') 110 | ->end() 111 | ->booleanNode('embed_source_map') 112 | ->info('Embed source map contents in CSS.') 113 | ->defaultValue('%kernel.debug%') 114 | ->end() 115 | ->arrayNode('load_path') 116 | ->info('Additional load paths') 117 | ->scalarPrototype() 118 | ->end() 119 | ->end() 120 | ->booleanNode('quiet') 121 | ->info('Don\'t print warnings.') 122 | ->end() 123 | ->booleanNode('quiet_deps') 124 | ->info(' Don\'t print compiler warnings from dependencies.') 125 | ->end() 126 | ->booleanNode('stop_on_error') 127 | ->info('Don\'t compile more files once an error is encountered.') 128 | ->end() 129 | ->booleanNode('trace') 130 | ->info('Print full Dart stack traces for exceptions.') 131 | ->end() 132 | ->end() 133 | ->end() 134 | ->booleanNode('embed_sourcemap') 135 | ->setDeprecated('symfonycast/sass-bundle', '0.4', 'Option "%node%" at "%path%" is deprecated. Use "sass_options.embed_source_map" instead".') 136 | ->defaultNull() 137 | ->end() 138 | ->end() 139 | ; 140 | 141 | return $treeBuilder; 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /src/Listener/PreAssetsCompileEventListener.php: -------------------------------------------------------------------------------- 1 | 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | namespace Symfonycasts\SassBundle\Listener; 11 | 12 | use Symfony\Component\AssetMapper\Event\PreAssetsCompileEvent; 13 | use Symfony\Component\Console\Input\ArrayInput; 14 | use Symfony\Component\Console\Style\SymfonyStyle; 15 | use Symfonycasts\SassBundle\SassBuilder; 16 | 17 | class PreAssetsCompileEventListener 18 | { 19 | public function __construct(private readonly SassBuilder $sassBuilder) 20 | { 21 | } 22 | 23 | public function __invoke(PreAssetsCompileEvent $preAssetsCompileEvent): void 24 | { 25 | $io = new SymfonyStyle( 26 | new ArrayInput([]), 27 | $preAssetsCompileEvent->getOutput() 28 | ); 29 | 30 | $this->sassBuilder->setOutput($io); 31 | 32 | $process = $this->sassBuilder->runBuild(false); 33 | 34 | $process->wait(function ($type, $buffer) use ($io) { 35 | $io->write($buffer); 36 | }); 37 | 38 | if ($process->isSuccessful()) { 39 | return; 40 | } 41 | 42 | throw new \RuntimeException(\sprintf('Error compiling sass: "%s"', $process->getErrorOutput())); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/SassBinary.php: -------------------------------------------------------------------------------- 1 | 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | namespace Symfonycasts\SassBundle; 11 | 12 | use Symfony\Component\Console\Style\SymfonyStyle; 13 | use Symfony\Component\HttpClient\HttpClient; 14 | use Symfony\Component\Process\Process; 15 | use Symfony\Contracts\HttpClient\HttpClientInterface; 16 | 17 | class SassBinary 18 | { 19 | private const VERSION = '1.69.7'; 20 | private HttpClientInterface $httpClient; 21 | 22 | public function __construct( 23 | private string $binaryDownloadDir, 24 | private ?string $binaryPath = null, 25 | private ?SymfonyStyle $output = null, 26 | ?HttpClientInterface $httpClient = null, 27 | ) { 28 | $this->httpClient = $httpClient ?? HttpClient::create(); 29 | } 30 | 31 | /** 32 | * @param array $args 33 | */ 34 | public function createProcess(array $args): Process 35 | { 36 | if (null === $this->binaryPath) { 37 | $binary = $this->getDefaultBinaryPath(); 38 | if (!is_file($binary)) { 39 | $this->downloadExecutable(); 40 | } 41 | } else { 42 | $binary = $this->binaryPath; 43 | } 44 | 45 | array_unshift($args, $binary); 46 | 47 | return new Process($args); 48 | } 49 | 50 | public function downloadExecutable(): void 51 | { 52 | $url = \sprintf('https://github.com/sass/dart-sass/releases/download/%s/%s', self::VERSION, $this->getBinaryName()); 53 | $isZip = str_ends_with($url, '.zip'); 54 | 55 | $this->output?->note('Downloading Sass binary from '.$url); 56 | 57 | if (!is_dir($this->binaryDownloadDir)) { 58 | mkdir($this->binaryDownloadDir, 0777, true); 59 | } 60 | 61 | $targetPath = $this->binaryDownloadDir.'/'.self::getBinaryName(); 62 | $progressBar = null; 63 | 64 | $response = $this->httpClient->request('GET', $url, [ 65 | 'on_progress' => function (int $dlNow, int $dlSize, array $info) use (&$progressBar): void { 66 | if (0 === $dlSize) { 67 | return; 68 | } 69 | 70 | if (!$progressBar) { 71 | $progressBar = $this->output?->createProgressBar($dlSize); 72 | } 73 | 74 | $progressBar?->setProgress($dlNow); 75 | }, 76 | ]); 77 | 78 | $fileHandler = fopen($targetPath, 'w'); 79 | foreach ($this->httpClient->stream($response) as $chunk) { 80 | fwrite($fileHandler, $chunk->getContent()); 81 | } 82 | 83 | fclose($fileHandler); 84 | $progressBar?->finish(); 85 | $this->output?->writeln(''); 86 | 87 | if ($isZip) { 88 | if (!\extension_loaded('zip')) { 89 | throw new \Exception('Cannot unzip the downloaded sass binary. Please install the "zip" PHP extension.'); 90 | } 91 | $archive = new \ZipArchive(); 92 | $archive->open($targetPath); 93 | $archive->extractTo($this->binaryDownloadDir); 94 | $archive->close(); 95 | unlink($targetPath); 96 | 97 | return; 98 | } else { 99 | $archive = new \PharData($targetPath); 100 | $archive->decompress(); 101 | $archive->extractTo($this->binaryDownloadDir); 102 | 103 | // delete the .tar (the .tar.gz is deleted below) 104 | unlink(substr($targetPath, 0, -3)); 105 | } 106 | 107 | unlink($targetPath); 108 | 109 | $binaryPath = $this->getDefaultBinaryPath(); 110 | if (!is_file($binaryPath)) { 111 | throw new \Exception(\sprintf('Could not find downloaded binary in "%s".', $binaryPath)); 112 | } 113 | 114 | chmod($binaryPath, 0777); 115 | } 116 | 117 | public function getBinaryName(): string 118 | { 119 | $os = strtolower(\PHP_OS); 120 | $machine = strtolower(php_uname('m')); 121 | 122 | if (str_contains($os, 'darwin')) { 123 | if ('arm64' === $machine) { 124 | return $this->buildBinaryFileName('macos-arm64'); 125 | } 126 | 127 | if ('x86_64' === $machine) { 128 | return $this->buildBinaryFileName('macos-x64'); 129 | } 130 | 131 | throw new \Exception(\sprintf('No matching machine found for Darwin platform (Machine: %s).', $machine)); 132 | } 133 | 134 | if (str_contains($os, 'linux')) { 135 | $baseName = file_exists('/etc/alpine-release') ? 'linux-musl' : 'linux'; 136 | if ('arm64' === $machine || 'aarch64' === $machine) { 137 | return $this->buildBinaryFileName($baseName.'-arm64'); 138 | } 139 | if ('x86_64' === $machine) { 140 | return $this->buildBinaryFileName($baseName.'-x64'); 141 | } 142 | 143 | throw new \Exception(\sprintf('No matching machine found for Linux platform (Machine: %s).', $machine)); 144 | } 145 | 146 | if (str_contains($os, 'win')) { 147 | if ('x86_64' === $machine || 'amd64' === $machine || 'i586' === $machine) { 148 | return $this->buildBinaryFileName('windows-x64', true); 149 | } 150 | 151 | throw new \Exception(\sprintf('No matching machine found for Windows platform (Machine: %s).', $machine)); 152 | } 153 | 154 | throw new \Exception(\sprintf('Unknown platform or architecture (OS: %s, Machine: %s).', $os, $machine)); 155 | } 156 | 157 | private function buildBinaryFileName(string $os, bool $isWindows = false): string 158 | { 159 | return 'dart-sass-'.self::VERSION.'-'.$os.($isWindows ? '.zip' : '.tar.gz'); 160 | } 161 | 162 | private function getDefaultBinaryPath(): string 163 | { 164 | return $this->binaryDownloadDir.'/dart-sass/sass'; 165 | } 166 | } 167 | -------------------------------------------------------------------------------- /src/SassBuilder.php: -------------------------------------------------------------------------------- 1 | 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | namespace Symfonycasts\SassBundle; 11 | 12 | use Symfony\Component\Console\Style\SymfonyStyle; 13 | use Symfony\Component\Process\ExecutableFinder; 14 | use Symfony\Component\Process\InputStream; 15 | use Symfony\Component\Process\Process; 16 | 17 | class SassBuilder 18 | { 19 | /** 20 | * Run "sass --help" to see all options. 21 | * 22 | * @see https://sass-lang.com/documentation/cli/dart-sass/ 23 | */ 24 | private const SASS_OPTIONS = [ 25 | // Input and Output 26 | '--style' => 'expanded', // Output style. [expanded (default), compressed] 27 | '--[no-]charset' => null, // Emit a @charset or BOM for CSS with non-ASCII characters. 28 | '--[no-]error-css' => null, // Emit a CSS file when an error occurs. 29 | '--load-path' => null, // Additional load paths 30 | // Source Maps 31 | '--[no-]source-map' => true, // Whether to generate source maps. (defaults to on) 32 | '--[no-]embed-sources' => null, // Embed source file contents in source maps. 33 | '--[no-]embed-source-map' => null, // Embed source map contents in CSS. 34 | // Warnings 35 | '--[no-]quiet' => null, // Don't print warnings. 36 | '--[no-]quiet-deps' => null, // Don't print deprecation warnings for dependencies. 37 | // Other 38 | '--[no-]stop-on-error' => null, // Don't compile more files once an error is encountered. 39 | '--[no-]trace' => null, // Print full Dart stack traces for exceptions. 40 | ]; 41 | 42 | private ?SymfonyStyle $output = null; 43 | 44 | /** 45 | * @var array 46 | */ 47 | private array $sassOptions; 48 | 49 | /** 50 | * @param array $sassPaths 51 | * @param array $sassOptions 52 | */ 53 | public function __construct( 54 | private readonly array $sassPaths, 55 | private readonly string $cssPath, 56 | private readonly string $projectRootDir, 57 | private readonly ?string $binaryPath, 58 | bool|array $sassOptions = [], 59 | ) { 60 | if (\is_bool($sassOptions)) { 61 | // Until 0.4, the $sassOptions argument was a boolean named $embedSourceMap 62 | trigger_deprecation('symfonycasts/sass-bundle', '0.4', 'Passing a boolean to embed the source map is deprecated. Set \'sass_options.embed_source_map\' instead.'); 63 | $sassOptions = ['embed_source_map' => $sassOptions]; 64 | // ...and source maps were always generated. 65 | $sassOptions['source_map'] = true; 66 | } 67 | 68 | $this->setOptions($sassOptions); 69 | } 70 | 71 | /** 72 | * @internal 73 | */ 74 | public static function guessCssNameFromSassFile(string $sassFile, string $outputDirectory): string 75 | { 76 | $fileName = basename($sassFile, '.scss'); 77 | 78 | return $outputDirectory.'/'.$fileName.'.output.css'; 79 | } 80 | 81 | public function runBuild(bool $watch): Process 82 | { 83 | $binary = $this->createBinary(); 84 | 85 | $args = [ 86 | ...$this->getScssCssTargets(), 87 | ...$this->getBuildOptions(['--watch' => $watch]), 88 | ]; 89 | 90 | $process = $binary->createProcess($args); 91 | if ($watch) { 92 | $process->setTimeout(null); 93 | $inputStream = new InputStream(); 94 | $process->setInput($inputStream); 95 | } 96 | 97 | $this->output?->note('Executing Sass (pass -v to see more details).'); 98 | if ($this->output?->isVerbose()) { 99 | $this->output->writeln([ 100 | ' Command:', 101 | ' '.$process->getCommandLine(), 102 | ]); 103 | } 104 | 105 | $process->start(); 106 | 107 | return $process; 108 | } 109 | 110 | /** 111 | * @return array 112 | */ 113 | public function getScssCssTargets(): array 114 | { 115 | $targets = []; 116 | foreach ($this->sassPaths as $sassPath) { 117 | if (!is_file($sassPath)) { 118 | throw new \Exception(\sprintf('Could not find Sass file: "%s"', $sassPath)); 119 | } 120 | 121 | $targets[] = $sassPath.':'.$this->guessCssNameFromSassFile($sassPath, $this->cssPath); 122 | } 123 | 124 | return $targets; 125 | } 126 | 127 | /** 128 | * @param array $options 129 | * 130 | * @return list 131 | */ 132 | public function getBuildOptions(array $options = []): array 133 | { 134 | $buildOptions = []; 135 | $options = [...self::SASS_OPTIONS, ...$this->sassOptions, ...$options]; 136 | foreach ($options as $option => $value) { 137 | // Set only the defined options. 138 | if (null === $value) { 139 | continue; 140 | } 141 | // --no-embed-source-map 142 | // --quiet 143 | if (str_starts_with($option, '--[no-]')) { 144 | $buildOptions[] = str_replace('[no-]', $value ? '' : 'no-', $option); 145 | continue; 146 | } 147 | // --style=compressed 148 | if (\is_string($value)) { 149 | $buildOptions[] = $option.'='.$value; 150 | continue; 151 | } 152 | // --load-path 153 | if (\is_array($value)) { 154 | foreach ($value as $item) { 155 | $buildOptions[] = $option.'='.$item; 156 | } 157 | continue; 158 | } 159 | // --update 160 | // --watch 161 | if ($value) { 162 | $buildOptions[] = $option; 163 | } 164 | } 165 | 166 | // Filter forbidden associations of options. 167 | if (\in_array('--no-source-map', $buildOptions, true)) { 168 | $buildOptions = array_diff($buildOptions, [ 169 | '--embed-sources', 170 | '--embed-source-map', 171 | '--no-embed-sources', 172 | '--no-embed-source-map', 173 | ]); 174 | } 175 | 176 | return array_values($buildOptions); 177 | } 178 | 179 | public function setOutput(SymfonyStyle $output): void 180 | { 181 | $this->output = $output; 182 | } 183 | 184 | private function createBinary(): SassBinary 185 | { 186 | $binaryPath = $this->binaryPath ?? (new ExecutableFinder())->find('sass'); 187 | 188 | return new SassBinary($this->projectRootDir.'/var', $binaryPath, $this->output); 189 | } 190 | 191 | /** 192 | * Save the Sass options for the build. 193 | * 194 | * Options are converted from PHP option names to CLI option names. 195 | * 196 | * @param array $options 197 | * 198 | * @see getOptionMap() 199 | */ 200 | private function setOptions(array $options = []): void 201 | { 202 | $sassOptions = []; 203 | $optionMap = $this->getOptionMap(); 204 | foreach ($options as $option => $value) { 205 | if (!isset($optionMap[$option])) { 206 | throw new \InvalidArgumentException(\sprintf('Invalid option "%s". Available options are: "%s".', $option, implode('", "', array_keys($optionMap)))); 207 | } 208 | $sassOptions[$optionMap[$option]] = $value; 209 | } 210 | $this->sassOptions = $sassOptions; 211 | } 212 | 213 | /** 214 | * Get a map of the Sass options as => . 215 | * Example: ['embed_source_map' => '--embed-source-map']. 216 | * 217 | * @return array 218 | */ 219 | private function getOptionMap(): array 220 | { 221 | $phpOptions = []; 222 | foreach (array_keys(self::SASS_OPTIONS) as $cliOption) { 223 | $phpOption = str_replace(['--[no-]', '--'], '', $cliOption); 224 | $phpOption = str_replace('-', '_', $phpOption); 225 | 226 | $phpOptions[$phpOption] = $cliOption; 227 | } 228 | 229 | return $phpOptions; 230 | } 231 | } 232 | -------------------------------------------------------------------------------- /src/SymfonycastsSassBundle.php: -------------------------------------------------------------------------------- 1 | 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | namespace Symfonycasts\SassBundle; 11 | 12 | use Symfony\Component\DependencyInjection\Extension\ExtensionInterface; 13 | use Symfony\Component\HttpKernel\Bundle\Bundle; 14 | use Symfonycasts\SassBundle\DependencyInjection\SymfonycastsSassExtension; 15 | 16 | class SymfonycastsSassBundle extends Bundle 17 | { 18 | protected function createContainerExtension(): ?ExtensionInterface 19 | { 20 | return new SymfonycastsSassExtension(); 21 | } 22 | } 23 | --------------------------------------------------------------------------------