├── .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 | [](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 |
--------------------------------------------------------------------------------