]> Raphaƫl G. Git Repositories - packbundle/blob - Parser/TokenParser.php
Add hash, short and unshort twig filter
[packbundle] / Parser / TokenParser.php
1 <?php
2
3 namespace Rapsys\PackBundle\Parser;
4
5 use Symfony\Component\Asset\PackageInterface;
6 use Symfony\Component\Filesystem\Exception\IOExceptionInterface;
7 use Symfony\Component\Filesystem\Filesystem;
8 use Symfony\Component\HttpKernel\Config\FileLocator;
9
10 use Twig\Error\Error;
11 use Twig\Node\Expression\AssignNameExpression;
12 use Twig\Node\Node;
13 use Twig\Node\SetNode;
14 use Twig\Node\TextNode;
15 use Twig\Source;
16 use Twig\Token;
17 use Twig\TokenParser\AbstractTokenParser;
18
19 class TokenParser extends AbstractTokenParser {
20 ///The tag name
21 protected $tag;
22
23 /**
24 * Constructor
25 *
26 * @param FileLocator $locator The FileLocator instance
27 * @param PackageInterface $package The Assets Package instance
28 * @param array $config The config path
29 * @param string $tag The tag name
30 * @param string $output The default output string
31 * @param array $filters The default filters array
32 */
33 public function __construct(FileLocator $locator, PackageInterface $package, array $config, string $tag, string $output, array $filters) {
34 //Save locator
35 $this->locator = $locator;
36
37 //Save assets package
38 $this->package = $package;
39
40 //Set name
41 $this->name = $config['name'];
42
43 //Set scheme
44 $this->scheme = $config['scheme'];
45
46 //Set timeout
47 $this->timeout = $config['timeout'];
48
49 //Set agent
50 $this->agent = $config['agent'];
51
52 //Set redirect
53 $this->redirect = $config['redirect'];
54
55 //Set tag
56 $this->tag = $tag;
57
58 //Set output
59 $this->output = $output;
60
61 //Set filters
62 $this->filters = $filters;
63 }
64
65 /**
66 * Get the tag name
67 *
68 * @return string This tag name
69 */
70 public function getTag(): string {
71 return $this->tag;
72 }
73
74 /**
75 * Parse the token
76 *
77 * @xxx Skip filter when debug mode is enabled is not possible
78 * @xxx This code is only run once when twig cache is enabled
79 * @xxx Twig cache value is not avaible in container parameters, maybe in twig env ?
80 *
81 * @param Token $token The \Twig\Token instance
82 * @return Node The PackNode
83 */
84 public function parse(Token $token): Node {
85 $parser = $this->parser;
86 $stream = $this->parser->getStream();
87
88 $inputs = [];
89 $name = $this->name;
90 $output = $this->output;
91 $filters = $this->filters;
92
93 $content = '';
94
95 //Process the token block until end
96 while (!$stream->test(Token::BLOCK_END_TYPE)) {
97 //The files to process
98 if ($stream->test(Token::STRING_TYPE)) {
99 //'somewhere/somefile.(css,img,js)' 'somewhere/*' '@jquery'
100 $inputs[] = $stream->next()->getValue();
101 //The filters token
102 } elseif ($stream->test(Token::NAME_TYPE, 'filters')) {
103 //filter='yui_js'
104 $stream->next();
105 $stream->expect(Token::OPERATOR_TYPE, '=');
106 $filters = array_merge($filters, array_filter(array_map('trim', explode(',', $stream->expect(Token::STRING_TYPE)->getValue()))));
107 //The output token
108 } elseif ($stream->test(Token::NAME_TYPE, 'output')) {
109 //output='js/packed/*.js' OR output='js/core.js'
110 $stream->next();
111 $stream->expect(Token::OPERATOR_TYPE, '=');
112 $output = $stream->expect(Token::STRING_TYPE)->getValue();
113 //The name token
114 } elseif ($stream->test(Token::NAME_TYPE, 'name')) {
115 //name='core_js'
116 $stream->next();
117 $stream->expect(Token::OPERATOR_TYPE, '=');
118 $name = $stream->expect(Token::STRING_TYPE)->getValue();
119 //Unexpected token
120 } else {
121 $token = $stream->getCurrent();
122 throw new Error(sprintf('Unexpected token "%s" of value "%s"', Token::typeToEnglish($token->getType()), $token->getValue()), $token->getLine(), $stream->getSourceContext());
123 }
124 }
125
126 //Process end block
127 $stream->expect(Token::BLOCK_END_TYPE);
128
129 //Process body
130 $body = $this->parser->subparse([$this, 'testEndTag'], true);
131
132 //Process end block
133 $stream->expect(Token::BLOCK_END_TYPE);
134
135 //Replace star with sha1
136 if (($pos = strpos($output, '*')) !== false) {
137 //XXX: assetic use substr(sha1(serialize($inputs).serialize($filters).serialize($options)), 0, 7)
138 $output = substr($output, 0, $pos).sha1(serialize($inputs).serialize($filters)).substr($output, $pos + 1);
139 }
140
141 //Process inputs
142 for($k = 0; $k < count($inputs); $k++) {
143 //Deal with generic url
144 if (strpos($inputs[$k], '//') === 0) {
145 //Fix url
146 $inputs[$k] = $this->scheme.substr($inputs[$k], 2);
147 //Deal with non url path
148 } elseif (strpos($inputs[$k], '://') === false) {
149 //Check if we have a bundle path
150 if ($inputs[$k][0] == '@') {
151 //Resolve it
152 $inputs[$k] = $this->getLocated($inputs[$k], $token->getLine(), $stream->getSourceContext());
153 }
154
155 //Deal with globs
156 if (strpos($inputs[$k], '*') !== false || (($a = strpos($inputs[$k], '{')) !== false && ($b = strpos($inputs[$k], ',', $a)) !== false && strpos($inputs[$k], '}', $b) !== false)) {
157 //Get replacement
158 $replacement = glob($inputs[$k], GLOB_NOSORT|GLOB_BRACE);
159 //Check that these are working files
160 foreach($replacement as $input) {
161 //Check that it's a file
162 if (!is_file($input)) {
163 throw new Error(sprintf('Input path "%s" from "%s" is not a file', $input, $inputs[$k]), $token->getLine(), $stream->getSourceContext());
164 }
165 }
166 //Replace with glob path
167 array_splice($inputs, $k, 1, $replacement);
168 //Fix current key
169 $k += count($replacement) - 1;
170 //Check that it's a file
171 } elseif (!is_file($inputs[$k])) {
172 throw new Error(sprintf('Input path "%s" is not a file', $inputs[$k]), $token->getLine(), $stream->getSourceContext());
173 }
174 }
175 }
176
177 //Init context
178 $ctx = stream_context_create(
179 [
180 'http' => [
181 'timeout' => $this->timeout,
182 'user_agent' => $this->agent,
183 'redirect' => $this->redirect,
184 ]
185 ]
186 );
187
188 //Check inputs
189 if (!empty($inputs)) {
190 //Retrieve files content
191 foreach($inputs as $input) {
192 //Try to retrieve content
193 if (($data = file_get_contents($input, false, $ctx)) === false) {
194 throw new Error(sprintf('Unable to retrieve input path "%s"', $input), $token->getLine(), $stream->getSourceContext());
195 }
196 //Append content
197 $content .= $data;
198 }
199 } else {
200 //Trigger error about empty inputs ?
201 //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
202 #throw new Error('Empty inputs token', $token->getLine(), $stream->getSourceContext());
203
204 //Send an empty node without inputs
205 return new Node();
206 }
207
208 //Check filters
209 if (!empty($filters)) {
210 //Apply all filters
211 foreach($filters as $filter) {
212 //Init args
213 $args = [$stream->getSourceContext(), $token->getLine()];
214 //Check if args is available
215 if (!empty($filter['args'])) {
216 //Append args if provided
217 $args += $filter['args'];
218 }
219 //Init reflection
220 $reflection = new \ReflectionClass($filter['class']);
221 //Set instance args
222 $tool = $reflection->newInstanceArgs($args);
223 //Process content
224 $content = $tool->process($content);
225 //Remove object
226 unset($tool, $reflection);
227 }
228 } else {
229 //Trigger error about empty filters ?
230 //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
231 #throw new Error('Empty filters token', $token->getLine(), $stream->getSourceContext());
232 }
233
234 //Retrieve asset uri
235 //XXX: this path is the merge of services.assets.path_package.arguments[0] and rapsys_pack.output.(css,img,js)
236 if (($outputUrl = $this->package->getUrl($output)) === false) {
237 throw new Error(sprintf('Unable to get url for asset: %s', $output), $token->getLine(), $stream->getSourceContext());
238 }
239
240 //Check if we have a bundle path
241 if ($output[0] == '@') {
242 //Resolve it
243 $output = $this->getLocated($output, $token->getLine(), $stream->getSourceContext());
244 }
245
246 //Get filesystem
247 $filesystem = new Filesystem();
248
249 //Create output dir if not present
250 if (!is_dir($dir = dirname($output))) {
251 try {
252 //Create dir
253 //XXX: set as 0775, symfony umask (0022) will reduce rights (0755)
254 $filesystem->mkdir($dir, 0775);
255 } catch (IOExceptionInterface $e) {
256 //Throw error
257 throw new Error(sprintf('Output directory "%s" do not exists and unable to create it', $dir), $token->getLine(), $stream->getSourceContext(), $e);
258 }
259 }
260
261 //Send file content
262 try {
263 //Write content to file
264 //XXX: this call is (maybe) atomic
265 //XXX: see https://symfony.com/doc/current/components/filesystem.html#dumpfile
266 $filesystem->dumpFile($output, $content);
267 } catch (IOExceptionInterface $e) {
268 //Throw error
269 throw new Error(sprintf('Unable to write to: %s', $output), $token->getLine(), $stream->getSourceContext(), $e);
270 }
271
272 //Set name in context key
273 $ref = new AssignNameExpression($name, $token->getLine());
274
275 //Set output in context value
276 $value = new TextNode($outputUrl, $token->getLine());
277
278 //Send body with context set
279 return new Node([
280 //This define name in twig template by prepending $context['<name>'] = '<output>';
281 new SetNode(true, $ref, $value, $token->getLine(), $this->getTag()),
282 //The tag captured body
283 $body
284 ]);
285 }
286
287 /**
288 * Test for tag end
289 *
290 * @param Token $token The \Twig\Token instance
291 * @return bool The token end test result
292 */
293 public function testEndTag(Token $token): bool {
294 return $token->test(['end'.$this->getTag()]);
295 }
296
297 /**
298 * Get path from bundled file
299 *
300 * @see https://symfony.com/doc/current/bundles.html#overridding-the-bundle-directory-structure
301 *
302 * @param string $file The bundled file path
303 * @param int $lineno The template line where the error occurred
304 * @param Source $source The source context where the error occurred
305 * @param Exception $prev The previous exception
306 * @return string The resolved file path
307 */
308 public function getLocated(string $file, int $lineno = 0, Source $source = null, \Exception $prev = null): string {
309 /*TODO: add a @jquery magic feature ?
310 if ($file == '@jquery') {
311 #header('Content-Type: text/plain');
312 #var_dump($inputs);
313 #exit;
314 return $this->config['jquery'];
315 }*/
316
317 //Check that we have a / separator between bundle name and path
318 if (($pos = strpos($file, '/')) === false) {
319 throw new Error(sprintf('Invalid path "%s"', $file), $token->getLine(), $stream->getSourceContext());
320 }
321
322 //Set bundle
323 $bundle = substr($file, 0, $pos);
324
325 //Set path
326 $path = substr($file, $pos + 1);
327
328 //Check for bundle suffix presence
329 //XXX: use "bundle templates automatic namespace" mimicked behaviour to find intended bundle and/or path
330 //XXX: see https://symfony.com/doc/4.3/templates.html#bundle-templates
331 if (strlen($bundle) < strlen('Bundle') || substr($bundle, -strlen('Bundle')) !== 'Bundle') {
332 //Append Bundle in an attempt to fix it's naming for locator
333 $bundle .= 'Bundle';
334
335 //Check for public resource prefix presence
336 if (strlen($path) < strlen('Resources/public') || substr($path, 0, strlen('Resources/public')) != 'Resources/public') {
337 //Prepend standard public path
338 $path = 'Resources/public/'.$path;
339 }
340 }
341
342 //Resolve bundle prefix
343 try {
344 $prefix = $this->locator->locate($bundle);
345 //Catch bundle does not exist or is not enabled exception
346 } catch(\InvalidArgumentException $e) {
347 //Fix lowercase first bundle character
348 if ($bundle[1] > 'Z' || $bundle[1] < 'A') {
349 $bundle[1] = strtoupper($bundle[1]);
350 }
351
352 //Detect double bundle suffix
353 if (strlen($bundle) > strlen('_bundleBundle') && substr($bundle, -strlen('_bundleBundle')) == '_bundleBundle') {
354 //Strip extra bundle
355 $bundle = substr($bundle, 0, -strlen('Bundle'));
356 }
357
358 //Convert snake case in camel case
359 if (strpos($bundle, '_') !== false) {
360 //Fix every first character following a _
361 while(($cur = strpos($bundle, '_')) !== false) {
362 $bundle = substr($bundle, 0, $cur).ucfirst(substr($bundle, $cur + 1));
363 }
364 }
365
366 //Resolve fixed bundle prefix
367 try {
368 $prefix = $this->locator->locate($bundle);
369 //Catch bundle does not exist or is not enabled exception again
370 } catch(\InvalidArgumentException $e) {
371 //Bail out as bundle or path is invalid and we have no way to know what was meant
372 throw new Error(sprintf('Invalid bundle name "%s" in path "%s". Maybe you meant "%s"', substr($file, 1, $pos - 1), $file, $bundle.'/'.$path), $token->getLine(), $stream->getSourceContext(), $e);
373 }
374 }
375
376 //Return solved bundle prefix and path
377 return $prefix.$path;
378 }
379 }