SymfonyCasts / tailwind-bundle

Delightful Tailwind Support for Symfony + AssetMapper
https://symfony.com/bundles/TailwindBundle/current/index.html
MIT License
84 stars 20 forks source link

SassBundle along with TailwindBundle #49

Open lukepass opened 8 months ago

lukepass commented 8 months ago

Hello, I posted a related comment in another thread of the SassBundle:

https://github.com/SymfonyCasts/sass-bundle/issues/4

Since both bundles are involved, I will repost it here, thanks:


Hello, so as of today it's not possible to use SassBundle along with TailwindBundle correct? That's because they compete for the same file to build?

I also tried giving tailwind the SASS path:

symfonycasts_tailwind:
    input_css: '%kernel.project_dir%/assets/styles/app.scss'

But then I get this error:

Unknown word
You tried to parse SCSS with the standard CSS parser; try again with the postcss-scss parser

That's because the tailwind parser doesn't know how to process the SASS file. I also tried giving tailwind the SASS output file:

symfonycasts_tailwind:
    input_css: '%kernel.project_dir%/var/sass/app.output.css'

But it does nothing.

How can I do to use Tailwind along with SASS and AssetMapper? Is it a bad practice?

Thanks.

squrious commented 8 months ago

Hello,

I managed to make them work together with this trick:

<?php

namespace App\AssetMapper;

use Symfony\Component\AssetMapper\AssetMapperInterface;
use Symfony\Component\AssetMapper\Compiler\AssetCompilerInterface;
use Symfony\Component\AssetMapper\MappedAsset;
use Symfony\Component\DependencyInjection\Attribute\AsDecorator;
use Symfony\Component\DependencyInjection\Attribute\Autowire;

#[AsDecorator('sass.css_asset_compiler')]
readonly class CssCompiler implements AssetCompilerInterface
{
    public function __construct(
        private AssetCompilerInterface $sassCompiler,
        #[Autowire('@tailwind.css_asset_compiler')]
        private AssetCompilerInterface $tailwindCompiler
    ) {
    }

    public function supports(MappedAsset $asset): bool
    {
        return $this->sassCompiler->supports($asset);
    }

    public function compile(string $content, MappedAsset $asset, AssetMapperInterface $assetMapper): string
    {
        $content = $this->sassCompiler->compile($content, $asset, $assetMapper);
        return $this->tailwindCompiler->compile($content, $asset, $assetMapper);
    }
}

And in config:

symfonycasts_tailwind:
  input_css: '%kernel.project_dir%/var/sass/app.output.css'

Entrypoint:

// assets/app.js

import './styles/app.scss';

Not sure it's the best way but it worked for me ^^

lukepass commented 8 months ago

That's quite clever but unfortunately it didn't work. The compiled SASS still includes @tailwind directives and they didn't get compiled.

I had to remove the @ from the service name because it was throwing an error.

Do you know why it's not working? I tried adding an exception / dump in the decorator but it didn't get called.

lukepass commented 8 months ago

I must correct myself. It "kinda" works but only when I run the command:

php bin/console asset-map:compile

And the first time it prints this error:

Built Tailwind CSS file does not exist: run "php bin/console tailwind:build" to   
  generate it

I had to manually run tailwind:build. In development it still doesn't work.

Thanks!

squrious commented 8 months ago

You have to run the sass build and then the tailwind build. In development both can be run with the --watch option in parallel so any change to app.scss will trigger rebuild from tailwind. I made a little demo here ;)

lukepass commented 8 months ago

Thanks, that worked; I can't understand the exact pipeline order but I can have SASS along with Tailwind. It would be nice to have this class already in the bundle!

CaptainFemur commented 4 months ago

Hi everyone ! I have the same issue. I have a repo with AssetMapper and SassBundle installed. I need to install the TailwindBundle, and I tried the @squrious's solution (from what I see, importants files are : the config in asset_mapper.yaml, the CssCompiler.php, and app.scss where you import tailwind rules).

Tailwind is loading correctly but my personal scss isn't recognize. I searched through the folder /var/tailwind and found that the css is build in the file app.output.built.css but Tailwind actually use another file : app.built.css (if I copy the content of the first one in the second one, it works).

How can I freely use TailwindBundle and SassBundle, am I missing something ?

#/assets/styles/app.scss
@tailwind base;
@tailwind components;
@tailwind utilities;

@import './variables';

body {
    background-color: $primary;
}
#/config/packages/asset_mapper.yaml
symfonycasts_tailwind:
    input_css: "%kernel.project_dir%/var/sass/app.output.css"
#/assets/app.js
import './bootstrap.js';
import './styles/app.scss';
import Duck from './modules/duck.js';

const duck = new Duck('Waddles');
duck.quack();
toby-griffiths commented 2 months ago

So I've spent a while trying to prep a Builder class that peformed both SASS & Tailwind build, inspired by @squrious 's code, but after a LOT of debugging I've come to discover that you don't need any of this. You just need to ensure that the SassBundle is listed before the TailwindBundle in the bundles.php file, then set the input file for both bundles to the file you want to compile, and then Symfony AssetMapper handler the rest…

# bundles.config/php
<?php

return [
    // … 
    // Other bundle orders do not matter, just `SymfonycastsSassBundle`
    // must be before `SymfonycastsTailwindBundle`…
    Symfonycasts\SassBundle\SymfonycastsSassBundle::class => ['all' => true],
    Symfonycasts\TailwindBundle\SymfonycastsTailwindBundle::class => ['all' => true],
    // … 
];
# config/packages/symfonycasts_sass.yaml
symfonycasts_sass:
  root_sass:  'assets/styles/app.scss'
# config/packages/symfonycasts_tailwind:.yaml
symfonycasts_tailwind:
    input_css: 'assets/styles/app.scss'

Then all you need to do is start watcher for both…

$ bin/console sass:build --watch
$ bin/console tailwind:build --watch

And for a Brucey Bonus, if you use Docker you can run these in a worker service like this…

# compose.override.yaml
services:
  php:
    # …
    volumes:
      # …
      - sass_var:/app/var/sass # <== Asset mapped needs to be able to see the compiled CSS file in dev as it checks for the existance of the output file
      - tailwind_var:/app/var/tailwind # <== This is where the `tailwind` compiled CSS file is written
    depends_on:
      # …
      - tailwind

  sass:
    # …
    volumes:
      # …
      - sass_var:/app/var/sass # <== This is where the compiled CSS files is written
    command: ['bin/console', 'sass:build', '--watch' ]

  tailwind:
    # …
    volumes:
      # …
      - sass_var:/app/var/sass # <== This is where the `sass` service outputs it's compiled CSS file
      - tailwind_var:/app/var/tailwind # <== This is where the compiled css file is written.  This is shared with the `php` services
    depends_on:
      # …
      - sass
    command: ['bin/console', 'tailwind:build', '--watch' ]
toby-griffiths commented 2 months ago

So it turns out my above solution doesn't work. This is because the builder and the compiler both use the symfonycasts_tailwind.input_css config, but the builder needs to point to the SASS output file, and the compiler will only compile if the symfonycasts_tailwind.input_css config value matches the original asset filename.

There were a coule of ways to 'fix' this…

  1. Add an additional TailwindSassAssetCompiler that has a modifier support() to match the original assert path;
  2. Modify the TailwindBuilder::$inputPath argument to use the SassBuilder's output path (from SassBuilder::guessCssNameFromSassFile());

I opted for the later, using a compiler pass that will only change the TailwindBuilder::$inputPath arg if the SassBuilder service exists, and the input path for Tailwind matches one of the Sass files configured…

<?php

namespace App;

use App\DependencyInjection\CompilerPass\TailwindSassBuildCommandPass;
use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\HttpKernel\Kernel as BaseKernel;

class Kernel extends BaseKernel
{
    use MicroKernelTrait;

    protected function build(ContainerBuilder $container): void
    {
        $container->addCompilerPass(new TailwindSassBuildCommandPass());
    }
}
<?php

declare(strict_types=1);

namespace App\DependencyInjection\CompilerPass;

use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfonycasts\SassBundle\SassBuilder;

use function in_array;

/**
 * If we're Tailwind compiling a SASS output file, then the TailwindCssAssetCompiler needs to be configured with the
 * original .scss file, but the builder needs to be configured with the .css file compiled by the SassBuilder, so we
 * need to tweak the input passed to the builder.
 *
 * We do this by checking that the Sas builder is available and that the input for the tailwind builder is the same as
 * one of the SASS input files.  If both these facts are true, we modify the input file passed to the TailwindBuilder,
 * so the assets compiled at runtime in dev work, and the `tailwind:build --watch` command also works for SASS files.
 */
final readonly class TailwindSassBuildCommandPass implements CompilerPassInterface
{

    public function process(ContainerBuilder $container): void
    {
        if (!$container->hasDefinition('sass.builder')) {
            return;
        }

        $sassBuilderDef = $container->findDefinition('sass.builder');
        $sassPaths = $sassBuilderDef->getArgument(0);
        $cssPath = $sassBuilderDef->getArgument(1);

        $tailWindBuilderDef = $container->findDefinition('tailwind.builder');
        $tailwindInputFile = $tailWindBuilderDef->getArgument(1);

        if (!in_array($tailwindInputFile, $sassPaths, true)) {
            return;
        }

        $tailWindBuilderDef->setArgument(1, SassBuilder::guessCssNameFromSassFile($tailwindInputFile, $cssPath));
    }
}

And the same config as above…

# config/packages/symfonycasts_sass.yaml
symfonycasts_sass:
  root_sass:  'assets/styles/app.scss'
# config/packages/symfonycasts_tailwind:.yaml
symfonycasts_tailwind:
    input_css: 'assets/styles/app.scss'
toby-griffiths commented 2 months ago

Ignore the above. I was working late. I now have a nice tidy solution that I'll create a Bundle for, but here's the idea…

Same source file for both Sass & Tailwind bundles (since we want both to process this file)…

# config/packages/symfonycasts_sass.yaml
symfonycasts_sass:
  root_sass:  'assets/styles/app.scss'
# config/packages/symfonycasts_tailwind:.yaml
symfonycasts_tailwind:
    input_css: 'assets/styles/app.scss'

Then we extend the TailwindBuilder with a Sass aware version. Note the $sassBuilder is 'optional' since it has to be added to the end of the list of args because the existing compiler passes for the Tailwind Bundle reference the arguments by number, not by name, so we can't screw them up.

<?php

declare(strict_types=1);

namespace CubicMushroom\TailwindSassBundle;

use Symfony\Contracts\Cache\CacheInterface;
use Symfonycasts\SassBundle\SassBuilder;
use Symfonycasts\TailwindBundle\TailwindBuilder;

use function explode;
use function realpath;

final class TailwindSassBuilder extends TailwindBuilder
{
    public function __construct(
        string $projectRootDir,
        string $inputPath,
        string $tailwindVarDir,
        CacheInterface $cache,
        ?string $binaryPath = null,
        ?string $binaryVersion = null,
        string $configPath = 'tailwind.config.js',
        ?SassBuilder $sassBuilder = null,
    ) {
        if ($sassBuilder) {
            foreach ($sassBuilder->getScssCssTargets() as $scssCssTarget) {
                [$sassFile, $cssFile] = explode(':', $scssCssTarget);
                if (realpath($sassFile) === realpath("$projectRootDir/$inputPath")) {
                    $inputPath = $cssFile;
                    break;
                }
            }
        }

        parent::__construct(
            $projectRootDir,
            $inputPath,
            $tailwindVarDir,
            $cache,
            $binaryPath,
            $binaryVersion,
            $configPath,
        );
    }
}

And you need a compiler pass to override the sass.builder service class and inject the SassBuilder instance…

The only problem with this is that calling SassBuilder::getScssCssTargets() in the context of a web request, rather than a console process is that the working directory is not the same, so we need to make sure the file paths are absolute. You can do this in your config files, but why enforce that when you can add a compiler pass to udpate the configs…

<?php

declare(strict_types=1);

namespace CubicMushroom\TailwindSassBundle\DependencyInjection\CompilerPass;

use LogicException;
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\Filesystem\Path;

/**
 * This pass ensures that the SASS file paths are absolute.
 *
 * Since we want to call `Symfonycasts\SassBundle\SassBuilder::getScssCssTargets()` within the web context, in
 * CubicMushroom\TailwindSassBundle\AssetMapper\TailwindSassAssetCompiler we need to ensure the SASS file paths are
 * absolute, since the working directory is /app in the console and /app/public for web requests.
 *
 * IMPORTANT: This needs to be run during the 'optimize' pass, so that CSS file route parameters are resolved.
 */
final readonly class AbsoluteSassPathsPass implements CompilerPassInterface
{

    public function process(ContainerBuilder $container): void
    {
        if (!$container->hasDefinition('sass.builder')) {
            throw new LogicException('The TailwindSassBundle requires the SymfonyCastsSassBundle to be installed.');
        }

        $sassBuilderDef = $container->findDefinition('sass.builder');
        $sassPaths = $sassBuilderDef->getArgument(0);
        $projectRootDir = $sassBuilderDef->getArgument(2);

        $sassPaths = array_map(
            fn(string $sassPath): string => Path::isAbsolute($sassPath)
                ? $sassPath
                : Path::makeAbsolute($sassPath, $projectRootDir),
            $sassPaths,
        );
        $sassBuilderDef->replaceArgument(0, $sassPaths);;
    }
}

And to add your compiler passes just register them in your Kernel in the usual way, but not the need to add the AbsoluteSassPathsPass one in the 'optimise' phase.

Since we now have a difference asset source to the TailwindBuilder input path, we just need to add a custom AssetCompilerInterface instance…

<?php

declare(strict_types=1);

namespace CubicMushroom\TailwindSassBundle\AssetMapper;

use Exception;
use Symfony\Component\AssetMapper\AssetMapperInterface;
use Symfony\Component\AssetMapper\Compiler\AssetCompilerInterface;
use Symfony\Component\AssetMapper\MappedAsset;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfonycasts\SassBundle\SassBuilder;
use Symfonycasts\TailwindBundle\TailwindBuilder;

/**
 * Custom compiler that allows the Tailwind compile file to be returned if the asset source is a SASS file that the
 * SassBuilder has mapped to the TailwindBuilder's input CSS file.
 */
class TailwindSassAssetCompiler implements AssetCompilerInterface
{
    /**
     * @var array<string, string>|null
     */
    private ?array $sassFilesMap;

    public function __construct(
        #[Autowire('@tailwind.builder')]
        private TailwindBuilder $tailwindBuilder,
        #[Autowire('@sass.builder')]
        private SassBuilder $sassBuilder,
    ) {
    }

    /**
     * @throws Exception
     */
    public function supports(MappedAsset $asset): bool
    {
        $sassFilesMap = $this->getSassFilesMap();

        return ($sassFilesMap[$asset->sourcePath] ?? null) === $this->tailwindBuilder->getInputCssPath();
    }

    public function compile(string $content, MappedAsset $asset, AssetMapperInterface $assetMapper): string
    {
        $asset->addFileDependency($this->tailwindBuilder->getInternalOutputCssPath());

        return $this->tailwindBuilder->getOutputCssContent();
    }

    /**
     * @throws Exception
     */
    private function getSassFilesMap(): array
    {
        if (!isset($this->sassFilesMap)) {
            $this->sassFilesMap = [];

            foreach ($this->sassBuilder->getScssCssTargets() as $scssCssTarget) {
                [$sassFile, $cssFile] = explode(':', $scssCssTarget);
                $this->sassFilesMap[$sassFile] = $cssFile;
            }
        }

        return $this->sassFilesMap;
    }
}

And finally, the AssetCompilerInterface instances don't get auto-tagged, so you need to add the asset_mapper.compiler tag when defining this in your services.{yaml,xml,php} file…

<?php

declare(strict_types=1);

namespace Symfony\Component\DependencyInjection\Loader\Configurator;

use CubicMushroom\TailwindSassBundle\AssetMapper\TailwindSassAssetCompiler;

return function (ContainerConfigurator $container): void {
    // default configuration for services in *this* file
    $services = $container->services()
        ->defaults()
        ->autowire()      // Automatically injects dependencies in your services.
        ->autoconfigure() // Automatically registers your services as commands, event subscribers, etc.
    ;

    $services->set(TailwindSassAssetCompiler::class)
        ->tag(
            'asset_mapper.compiler',
            [
                'priority' => 10,
            ],
        );
};
lukepass commented 2 months ago

Is there a chance that the previous version will be included in the base bundle?

Also please not that the version I was using in the first posts of this thread (the one using CssCompiler) doesn't work anymore in the latest version of this bundle.