1 <?php 
declare(strict_types
=1); 
   4  * This file is part of the Rapsys PackBundle package. 
   6  * (c) Raphaël Gertz <symfony@rapsys.eu> 
   8  * For the full copyright and license information, please view the LICENSE 
   9  * file that was distributed with this source code. 
  12 namespace Rapsys\PackBundle\Parser
; 
  14 use Psr\Container\ContainerInterface
; 
  16 use Rapsys\PackBundle\RapsysPackBundle
; 
  17 use Rapsys\PackBundle\Util\SluggerUtil
; 
  19 use Symfony\Component\Asset\PackageInterface
; 
  20 use Symfony\Component\Filesystem\Exception\IOExceptionInterface
; 
  21 use Symfony\Component\Filesystem\Filesystem
; 
  22 use Symfony\Component\HttpKernel\Config\FileLocator
; 
  23 use Symfony\Component\Routing\Exception\InvalidParameterException
; 
  24 use Symfony\Component\Routing\Exception\MissingMandatoryParametersException
; 
  25 use Symfony\Component\Routing\Exception\RouteNotFoundException
; 
  26 use Symfony\Component\Routing\RouterInterface
; 
  29 use Twig\Node\Expression\AssignNameExpression
; 
  31 use Twig\Node\SetNode
; 
  32 use Twig\Node\TextNode
; 
  35 use Twig\TokenParser\AbstractTokenParser
; 
  40 class TokenParser 
extends AbstractTokenParser 
{ 
  44         protected array $filters; 
  49         protected string $output; 
  54         protected string $route; 
  59         protected string $token; 
  64          * @param ContainerInterface $container The ContainerInterface instance 
  65          * @param FileLocator $locator The FileLocator instance 
  66          * @param RouterInterface $router The RouterInterface instance 
  67          * @param SluggerUtil $slugger The SluggerUtil instance 
  68          * @param array $config The config 
  69          * @param mixed $ctx The context stream instance 
  70          * @param string $prefix The output prefix 
  71          * @param string $tag The tag name 
  73         public function __construct(protected ContainerInterface 
$container, protected FileLocator 
$locator, protected RouterInterface 
$router, protected SluggerUtil 
$slugger, protected array $config, protected mixed $ctx, protected string $prefix, protected string $tag) { 
  75                 $this->filters 
= $config['filters'][$prefix]; 
  78                 $this->output 
= $config['public'].'/'.$config['prefixes']['pack'].'/'.$config['prefixes'][$prefix].'/*.'.$prefix; 
  81                 $this->route 
= $config['routes'][$prefix]; 
  84                 $this->token 
= $config['tokens'][$prefix]; 
  90          * @return string This tag name 
  92         public function getTag(): string { 
  99          * @xxx Skip filter when debug mode is enabled is not possible 
 100          * @xxx This code is only run once when twig cache is enabled 
 101          * @xxx Twig cache value is not avaible in container parameters, maybe in twig env ? 
 103          * @param Token $token The \Twig\Token instance 
 104          * @return Node The PackNode 
 106         public function parse(Token 
$token): Node 
{ 
 108                 $parser = $this->parser
; 
 111                 $stream = $this->parser
->getStream(); 
 119                 //Process the token block until end 
 120                 while (!$stream->test(Token
::BLOCK_END_TYPE
)) { 
 121                         //The files to process 
 122                         if ($stream->test(Token
::STRING_TYPE
)) { 
 123                                 //'somewhere/somefile.(css|img|js)' 'somewhere/*' '@jquery' 
 124                                 $inputs[] = $stream->next()->getValue(); 
 126                         } elseif ($stream->test(Token
::NAME_TYPE
, 'filters')) { 
 129                                 $stream->expect(Token
::OPERATOR_TYPE
, '='); 
 130                                 $this->filters 
= array_merge($this->filters
, array_filter(array_map('trim', explode(',', $stream->expect(Token
::STRING_TYPE
)->getValue())))); 
 132                         } elseif ($stream->test(Token
::NAME_TYPE
, 'route')) { 
 133                                 //output='rapsyspack_css' OR output='rapsyspack_js' OR output='rapsyspack_img' 
 135                                 $stream->expect(Token
::OPERATOR_TYPE
, '='); 
 136                                 $this->route 
= $stream->expect(Token
::STRING_TYPE
)->getValue(); 
 138                         } elseif ($stream->test(Token
::NAME_TYPE
, 'output')) { 
 139                                 //output='js/packed/*.js' OR output='js/core.js' 
 141                                 $stream->expect(Token
::OPERATOR_TYPE
, '='); 
 142                                 $this->output 
= $stream->expect(Token
::STRING_TYPE
)->getValue(); 
 143                         //TODO: add format ? jpeg|png|gif|webp|webm ??? 
 145                         } elseif ($stream->test(Token
::NAME_TYPE
, 'token')) { 
 148                                 $stream->expect(Token
::OPERATOR_TYPE
, '='); 
 149                                 $this->token 
= $stream->expect(Token
::STRING_TYPE
)->getValue(); 
 152                                 $token = $stream->getCurrent(); 
 154                                 throw new Error(sprintf('Unexpected token "%s" of value "%s"', Token
::typeToEnglish($token->getType()), $token->getValue()), $token->getLine(), $stream->getSourceContext()); 
 159                 $stream->expect(Token
::BLOCK_END_TYPE
); 
 162                 $body = $this->parser
->subparse([$this, 'testEndTag'], true); 
 165                 $stream->expect(Token
::BLOCK_END_TYPE
); 
 167                 //Without valid output 
 168                 if (($pos = strpos($this->output
, '*')) === false || $pos !== strrpos($this->output
, '*')) { 
 170                         throw new Error(sprintf('Invalid output "%s"', $this->output
), $token->getLine(), $stream->getSourceContext()); 
 173                 //Without existing route 
 174                 if ($this->router
->getRouteCollection()->get($this->route
) === null) { 
 176                         throw new Error(sprintf('Invalid route "%s"', $this->route
), $token->getLine(), $stream->getSourceContext()); 
 180                 //XXX: assetic use substr(sha1(serialize($inputs).serialize($this->filters).serialize($this->output)), 0, 7) 
 181                 $file = $this->slugger
->hash([$inputs, $this->filters
, $this->output
, $this->route
, $this->token
]); 
 183                 //Replace star by file 
 184                 $this->output 
= substr($this->output
, 0, $pos).$file.substr($this->output
, $pos + 
1); 
 187                 for($k = 0; $k < count($inputs); $k++
) { 
 188                         //Deal with generic url 
 189                         if (strpos($inputs[$k], '//') === 0) { 
 191                                 $inputs[$k] = ($_ENV['RAPSYSPACK_SCHEME'] ?? 'https').'://'.substr($inputs[$k], 2); 
 192                         //Deal with non url path 
 193                         } elseif (strpos($inputs[$k], '://') === false) { 
 194                                 //Check if we have a bundle path 
 195                                 if ($inputs[$k][0] == '@') { 
 197                                         $inputs[$k] = $this->getLocated($inputs[$k], $token->getLine(), $stream->getSourceContext()); 
 201                                 if (strpos($inputs[$k], '*') !== false || (($a = strpos($inputs[$k], '{')) !== false && ($b = strpos($inputs[$k], ',', $a)) !== false && strpos($inputs[$k], '}', $b) !== false)) { 
 203                                         $replacement = glob($inputs[$k], GLOB_NOSORT
|GLOB_BRACE
); 
 205                                         //Check that these are working files 
 206                                         foreach($replacement as $input) { 
 207                                                 //Check that it's a file 
 208                                                 if (!is_file($input)) { 
 210                                                         throw new Error(sprintf('Input path "%s" from "%s" is not a file', $input, $inputs[$k]), $token->getLine(), $stream->getSourceContext()); 
 214                                         //Replace with glob path 
 215                                         array_splice($inputs, $k, 1, $replacement); 
 218                                         $k +
= count($replacement) - 1; 
 219                                 //Check that it's a file 
 220                                 } elseif (!is_file($inputs[$k])) { 
 222                                         throw new Error(sprintf('Input path "%s" is not a file', $inputs[$k]), $token->getLine(), $stream->getSourceContext()); 
 227                 #TODO: move the inputs reading from here to inside the filters ? 
 230                 if (!empty($inputs)) { 
 231                         //Retrieve files content 
 232                         foreach($inputs as $input) { 
 233                                 //Try to retrieve content 
 234                                 if (($data = file_get_contents($input, false, $this->ctx
)) === false) { 
 236                                         throw new Error(sprintf('Unable to retrieve input path "%s"', $input), $token->getLine(), $stream->getSourceContext()); 
 243                         //Trigger error about empty inputs ? 
 244                         //XXX: There may be a legitimate case where we want an empty file or an error, feel free to contact the author in such case 
 245                         #throw new Error('Empty inputs token', $token->getLine(), $stream->getSourceContext()); 
 247                         //Send an empty node without inputs 
 252                 if (!empty($this->filters
)) { 
 254                         foreach($this->filters 
as $filter) { 
 256                                 $args = [$stream->getSourceContext(), $token->getLine()]; 
 258                                 //Check if args is available 
 259                                 if (!empty($filter['args'])) { 
 260                                         //Append args if provided 
 261                                         $args +
= $filter['args']; 
 265                                 $reflection = new \
ReflectionClass($filter['class']); 
 268                                 $tool = $reflection->newInstanceArgs($args); 
 271                                 $content = $tool->process($content); 
 274                                 unset($tool, $reflection); 
 277                         //Trigger error about empty filters ? 
 278                         //XXX: There may be a legitimate case where we want only a merged file or an error, feel free to contact the author in such case 
 279                         #throw new Error('Empty filters token', $token->getLine(), $stream->getSourceContext()); 
 282                 //Check if we have a bundle path 
 283                 if ($this->output
[0] == '@') { 
 285                         $this->output 
= $this->getLocated($this->output
, $token->getLine(), $stream->getSourceContext()); 
 289                 $filesystem = new Filesystem(); 
 291                 //Create output dir if not present 
 292                 if (!is_dir($dir = dirname($this->output
))) { 
 295                                 //XXX: set as 0775, symfony umask (0022) will reduce rights (0755) 
 296                                 $filesystem->mkdir($dir, 0775); 
 297                         } catch (IOExceptionInterface 
$e) { 
 299                                 throw new Error(sprintf('Output directory "%s" do not exists and unable to create it', $dir), $token->getLine(), $stream->getSourceContext(), $e); 
 305                         //Write content to file 
 306                         //XXX: this call is (maybe) atomic 
 307                         //XXX: see https://symfony.com/doc/current/components/filesystem.html#dumpfile 
 308                         $filesystem->dumpFile($this->output
, $content); 
 309                 } catch (IOExceptionInterface 
$e) { 
 311                         throw new Error(sprintf('Unable to write "%s"', $this->output
), $token->getLine(), $stream->getSourceContext(), $e); 
 314                 //Without output file mtime 
 315                 if (($mtime = filemtime($this->output
)) === false) { 
 317                         throw new Error(sprintf('Unable to get "%s" mtime', $this->output
), $token->getLine(), $stream->getSourceContext(), $e); 
 320                 //TODO: get mimetype for images ? and set _format ? 
 324                         $asset = $this->router
->generate($this->route
, [ 'file' => $file, 'u' => $mtime ]); 
 325                 //Catch router exceptions 
 326                 } catch (RouteNotFoundException
|MissingMandatoryParametersException
|InvalidParameterException 
$e) { 
 328                         throw new Error(sprintf('Unable to generate asset route "%s"', $this->route
), $token->getLine(), $stream->getSourceContext(), $e); 
 331                 //Set name in context key 
 332                 $ref = new AssignNameExpression($this->token
, $token->getLine()); 
 334                 //Set output in context value 
 335                 $value = new TextNode($asset, $token->getLine()); 
 337                 //Send body with context set 
 339                         //This define name in twig template by prepending $context['<name>'] = '<output>'; 
 340                         new SetNode(true, $ref, $value, $token->getLine(), $this->getTag()), 
 341                         //The tag captured body 
 349          * @param Token $token The \Twig\Token instance 
 350          * @return bool The token end test result 
 352         public function testEndTag(Token 
$token): bool { 
 353                 return $token->test(['end'.$this->getTag()]); 
 357          * Get path from bundled file 
 359          * @see https://symfony.com/doc/current/bundles.html#overridding-the-bundle-directory-structure 
 361          * @param string $file The bundled file path 
 362          * @param int $lineno The template line where the error occurred 
 363          * @param Source $source The source context where the error occurred 
 364          * @param Exception $prev The previous exception 
 365          * @return string The resolved file path 
 367         public function getLocated(string $file, int $lineno = 0, ?Source 
$source = null, ?\Exception 
$prev = null): string { 
 368                 /*TODO: add a @jquery magic feature ? 
 369                 if ($file == '@jquery') { 
 370                         #header('Content-Type: text/plain'); 
 373                         return $this->config['jquery']; 
 377                 if (($bundle = strstr($file, '/', true)) === false) { 
 378                         throw new Error(sprintf('Invalid bundle "%s"', $file), $lineno, $source); 
 382                 if (($path = strstr($file, '/')) === false) { 
 383                         throw new Error(sprintf('Invalid path "%s"', $file), $lineno, $source); 
 387                 $alias = strtolower(substr($bundle, 1)); 
 389                 //With public parameter 
 390                 if ($this->container
->hasParameter($alias.'.public')) { 
 392                         $prefix = $this->container
->getParameter($alias.'.public'); 
 393                 //Without public parameter 
 395                         //Without bundle suffix presence 
 396                         //XXX: use "bundle templates automatic namespace" mimicked behaviour to find intended bundle and/or path 
 397                         //XXX: see https://symfony.com/doc/4.3/templates.html#bundle-templates 
 398                         if (strlen($bundle) < strlen('@Bundle') || substr($bundle, -strlen('Bundle')) !== 'Bundle') { 
 403                         //Try to resolve bundle prefix 
 405                                 $prefix = $this->locator
->locate($bundle); 
 406                         //Catch bundle does not exist or is not enabled exception 
 407                         } catch(\InvalidArgumentException 
$e) { 
 408                                 throw new Error(sprintf('Unlocatable bundle "%s"', $bundle), $lineno, $source, $e); 
 411                         //With Resources/public subdirectory 
 412                         if (is_dir($prefix.'Resources/public')) { 
 413                                 $prefix .= 'Resources/public'; 
 414                         //With public subdirectory 
 415                         } elseif (is_dir($prefix.'public')) { 
 417                         //Without any public subdirectory 
 419                                 throw new Error(sprintf('Bundle "%s" lacks a public subdirectory', $bundle), $lineno, $source, $e); 
 423                 //Return solved bundle prefix and path 
 424                 return $prefix.$path;