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;