Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ composer.lock
.php-cs-fixer.cache
.phpunit.result.cache
/tests/fixtures/var
/.idea
3 changes: 2 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@
"symfony/console": "^5.4|^6.3|^7.0|^8.0",
"symfony/filesystem": "^5.4|^6.3|^7.0|^8.0",
"symfony/http-client": "^5.4|^6.3|^7.0|^8.0",
"symfony/process": "^5.4|^6.3|^7.0|^8.0"
"symfony/process": "^5.4|^6.3|^7.0|^8.0",
"symfony/finder": "^5.4|^6.3|^7.0|^8.0"
},
"require-dev": {
"matthiasnoback/symfony-config-test": "^5.0|^6.0",
Expand Down
12 changes: 12 additions & 0 deletions doc/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,18 @@ This represents the source Sass file:
root_sass:
- '%kernel.project_dir%/assets/scss/app.scss'

.. note::

The ``root_sass`` option also supports glob patterns for source Sass files:

.. code-block:: yaml

symfonycasts_sass:
root_sass:
- '%kernel.project_dir%/assets/scss/app.scss'
- '%kernel.project_dir%/assets/lib/*.scss'
- '%kernel.project_dir%/assets/admin/**/*.scss'

Sass CLI Options
~~~~~~~~~~~~~~~~

Expand Down
10 changes: 7 additions & 3 deletions src/AssetMapper/SassCssCompiler.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
use Symfony\Component\AssetMapper\MappedAsset;
use Symfony\Component\Filesystem\Path;
use Symfonycasts\SassBundle\SassBuilder;
use Symfonycasts\SassBundle\SassFileHelper;

class SassCssCompiler implements AssetCompilerInterface
{
Expand All @@ -27,11 +28,14 @@ public function __construct(

public function supports(MappedAsset $asset): bool
{
$helper = new SassFileHelper();
foreach ($this->scssPaths as $path) {
$absolutePath = Path::isAbsolute($path) ? $path : Path::makeAbsolute($path, $this->projectDir);
foreach ($helper->resolveSassInput($path, $this->projectDir) as $resolvedFile) {
$absolutePath = Path::isAbsolute($resolvedFile) ? $resolvedFile : Path::makeAbsolute($resolvedFile, $this->projectDir);

if (realpath($asset->sourcePath) === realpath($absolutePath)) {
return true;
if (realpath($asset->sourcePath) === realpath($absolutePath)) {
return true;
}
}
}

Expand Down
9 changes: 4 additions & 5 deletions src/DependencyInjection/SymfonycastsSassExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -71,15 +71,14 @@ public function getConfigTreeBuilder(): TreeBuilder
return false;
}

$filenames = [];
$uniqueFilenames = [];
foreach ($paths as $path) {
$filename = basename($path, '.scss');
$filenames[$filename] = $filename;
$uniqueFilenames[$path] = true;
}

return \count($filenames) !== \count($paths);
return \count($uniqueFilenames) !== \count($paths);
})
->thenInvalid('The "root_sass" paths need to end with unique filenames.')
->thenInvalid('The "root_sass" paths must be unique (duplicate entries found).')
->end()
->defaultValue(['%kernel.project_dir%/assets/styles/app.scss'])
->end()
Expand Down
9 changes: 5 additions & 4 deletions src/SassBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ public function __construct(
public static function guessCssNameFromSassFile(string $sassFile, string $outputDirectory): string
{
$fileName = basename($sassFile, '.scss');
$fileName = SassFileHelper::hashFilename($fileName);

return $outputDirectory.'/'.$fileName.'.output.css';
}
Expand Down Expand Up @@ -113,12 +114,12 @@ public function runBuild(bool $watch): Process
public function getScssCssTargets(): array
{
$targets = [];
$helper = new SassFileHelper();

foreach ($this->sassPaths as $sassPath) {
if (!is_file($sassPath)) {
throw new \Exception(\sprintf('Could not find Sass file: "%s"', $sassPath));
foreach ($helper->resolveSassInput($sassPath, $this->projectRootDir) as $resolvedFile) {
$targets[] = $resolvedFile.':'.$this->guessCssNameFromSassFile($resolvedFile, $this->cssPath);
}

$targets[] = $sassPath.':'.$this->guessCssNameFromSassFile($sassPath, $this->cssPath);
}

return $targets;
Expand Down
212 changes: 212 additions & 0 deletions src/SassFileHelper.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
<?php

/*
* This file is part of the SymfonyCasts SassBundle package.
* Copyright (c) SymfonyCasts <https://symfonycasts.com/>
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Symfonycasts\SassBundle;

use Symfony\Component\Filesystem\Path;
use Symfony\Component\Finder\Finder;

/**
* @internal
*/
final class SassFileHelper
{
/**
* Expands a configured sass path into concrete input files.
*
* Supports:
* - an existing file
* - an existing directory (equivalent to <dir>/ ** / *)
* - glob patterns including ** (matched against relative path under the base dir)
*
* Ignores files whose *basename* starts with "_" (Sass partials).
*
* @return array<string> absolute file paths
*/
public function resolveSassInput(string $sassPath, ?string $baseDir = null): array
{
if (null !== $baseDir && !Path::isAbsolute($sassPath)) {
$sassPath = Path::makeAbsolute($sassPath, $baseDir);
}

// 1) Directory: treat as "<dir>/**/*" (any extension)
if (is_dir($sassPath)) {
$finder = new Finder();
$finder
->files()
->in($sassPath) // recursive by default
->ignoreDotFiles(true)
->ignoreVCS(true)
->notName('_*')
->sortByName()
;

$files = [];
foreach ($finder as $file) {
$files[] = $file->getRealPath() ?: $file->getPathname();
}

return $files;
}

// 2) Exact file
if (is_file($sassPath)) {
return str_starts_with(basename($sassPath), '_') ? [] : [$sassPath];
}

// 3) Glob/pattern (supports **)
if (!$this->looksLikeGlob($sassPath)) {
// Not a glob, so just return the path as-is
return [$sassPath];
}

[$baseDirFromGlob, $relativeGlob] = $this->splitGlobBaseDir($sassPath);

if (!is_dir($baseDirFromGlob)) {
throw new \Exception(\sprintf('Could not find Sass directory: "%s" (from "%s")', $baseDirFromGlob, $sassPath));
}

$regex = $this->globToRegex($relativeGlob);

$finder = new Finder();
$finder
->files()
->in($baseDirFromGlob) // recursive
->ignoreDotFiles(true)
->ignoreVCS(true)
->notName('_*')
->sortByName()
;

$files = [];
foreach ($finder as $file) {
$rel = self::normalizePath($file->getRelativePathname());

if (!preg_match($regex, $rel)) {
continue;
}

$files[] = self::normalizePath($file->getRealPath() ?: $file->getPathname());
}

return $files;
}

public static function hashFilename(string $filename): string
{
// Normalize the path to avoid issues with different OS.
$normalized = self::normalizePath(realpath($filename) ?: $filename);

// Hash the file path to create a unique filename.
$hash = substr(sha1($normalized), 0, 10);

return $filename.'-'.$hash;
}

public static function normalizePath(string $path): string
{
return str_replace('\\', '/', $path);
}

private function looksLikeGlob(string $path): bool
{
return false !== strpbrk($path, '*?[');
}

/**
* Splits an absolute glob path into:
* - base directory (no glob tokens)
* - remaining glob relative to that base directory
*
* @return array{0:string,1:string}
*/
private function splitGlobBaseDir(string $absoluteGlobPath): array
{
$p = self::normalizePath($absoluteGlobPath);

$positions = array_filter([
strpos($p, '*'),
strpos($p, '?'),
strpos($p, '['),
], static fn ($v) => false !== $v);

$firstGlobPos = min($positions);

$slashPos = strrpos(substr($p, 0, $firstGlobPos), '/');
if (false === $slashPos) {
return ['.', $p];
}

$baseDir = substr($p, 0, $slashPos);
$relativeGlob = ltrim(substr($p, $slashPos + 1), '/');

return [$baseDir, $relativeGlob];
}

/**
* Converts a glob (using "/" separators) into a regex that matches the *entire* relative pathname.
*
* Tokens:
* - ** => ".*" (any number of path segments)
* - * => "[^/]*"
* - ? => "[^/]"
* - [..] character class is passed through best-effort
*/
private function globToRegex(string $glob): string
{
$g = self::normalizePath($glob);

$re = '';
$len = \strlen($g);

for ($i = 0; $i < $len; ++$i) {
$ch = $g[$i];

if ('*' === $ch && ($i + 1 < $len) && '*' === $g[$i + 1]) {
++$i;
$re .= '.*';
continue;
}

if ('*' === $ch) {
$re .= '[^/]*';
continue;
}

if ('?' === $ch) {
$re .= '[^/]';
continue;
}

if ('[' === $ch) {
$end = strpos($g, ']', $i + 1);
if (false === $end) {
$re .= '\[';
} else {
$re .= substr($g, $i, $end - $i + 1);
$i = $end;
}
continue;
}

if ('~' === $ch) {
$re .= '\~';
continue;
}

if (preg_match('/[.()+^$|{}\\\\]/', $ch)) {
$re .= '\\'.$ch;
} else {
$re .= $ch;
}
}

return '~^'.$re.'$~u';
}
}
27 changes: 26 additions & 1 deletion tests/AssetMapper/SassCssCompilerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,31 @@ public function testSupports()
$builder
);

$this->assertTrue($compilerRelativePath->supports($asset), 'Supportes relative paths');
$this->assertTrue($compilerRelativePath->supports($asset), 'Supports relative paths');
}

public function testSupportsGlob()
{
$builder = $this->createMock(SassBuilder::class);

$asset = new MappedAsset('assets/lib/libcss.scss', __DIR__.'/../fixtures/assets/lib/libcss.scss');

$compilerAbsolutePath = new SassCssCompiler(
[__DIR__.'/../fixtures/assets/*.scss', __DIR__.'/../fixtures/assets/**/*.scss'],
__DIR__.'/../fixtures/var/sass',
__DIR__.'/../fixtures',
$builder
);

$this->assertTrue($compilerAbsolutePath->supports($asset), 'Supports absolute paths');

$compilerRelativePath = new SassCssCompiler(
['assets/lib/*.scss', 'assets/lib/**/*.scss'],
__DIR__.'/../fixtures/var/sass',
__DIR__.'/../fixtures',
$builder
);

$this->assertTrue($compilerRelativePath->supports($asset), 'Supports relative paths');
}
}
Loading
Loading